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,102 @@
|
|
|
1
|
+
require "pragmatic_segmenter"
|
|
2
|
+
|
|
3
|
+
# Default segmenter: scores 76/80 on the Golden Rules corpus vs Simple's 47/80 (golden_rules_test.rb).
|
|
4
|
+
class TranslationDiff::Segmenters::Pragmatic
|
|
5
|
+
# Raised only if computed offsets violate their own postcondition, never by ordinary sentence rewriting.
|
|
6
|
+
class Error < TranslationDiff::Error; end
|
|
7
|
+
|
|
8
|
+
# Without a language, Russian mis-segments: it treats "Проф." as a full sentence and stops there.
|
|
9
|
+
DEFAULT_LANGUAGE = "en".freeze
|
|
10
|
+
|
|
11
|
+
# A lone "\n" is incidental source formatting, not a paragraph break; a run of two or more is left alone.
|
|
12
|
+
SINGLE_NEWLINE = /(?<!\n)\n(?!\n)/
|
|
13
|
+
|
|
14
|
+
def self.build(_config) = new
|
|
15
|
+
|
|
16
|
+
def split_offsets(text, language: nil)
|
|
17
|
+
return [0] unless split_candidate?(text)
|
|
18
|
+
|
|
19
|
+
shadow = shadow_newlines(text)
|
|
20
|
+
sentences = segment(shadow, language)
|
|
21
|
+
return [0] if sentences.size <= 1
|
|
22
|
+
|
|
23
|
+
offsets = recover_offsets(shadow, sentences)
|
|
24
|
+
assert_valid_offsets(text, offsets)
|
|
25
|
+
offsets
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def split_candidate?(text)
|
|
31
|
+
text.is_a?(String) && !text.strip.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# pragmatic_segmenter treats any single newline as a sentence boundary, confirmed false on wrapped prose.
|
|
35
|
+
def shadow_newlines(text)
|
|
36
|
+
text.gsub(SINGLE_NEWLINE, " ")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# DeepL sends codes like "EN-GB"; the lookup is case-sensitive, so without this "RU" falls through to Common.
|
|
40
|
+
def normalize_language(language)
|
|
41
|
+
code = language.to_s.downcase.split(/[-_]/, 2).first
|
|
42
|
+
return DEFAULT_LANGUAGE unless PragmaticSegmenter::Languages::LANGUAGE_CODES.key?(code)
|
|
43
|
+
|
|
44
|
+
code
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def segment(shadow, language)
|
|
48
|
+
PragmaticSegmenter::Segmenter.new(text: shadow, language: normalize_language(language)).segment
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Pinned 0.3.24's cleaner rewrites text (collapses spaces, respaces "Ph.D."), so some sentences can't be located.
|
|
52
|
+
def recover_offsets(shadow, sentences)
|
|
53
|
+
starts, cursor, stopped_early = walk(shadow, sentences)
|
|
54
|
+
|
|
55
|
+
# The first located sentence's own start is never a split point; only the ones after it are.
|
|
56
|
+
offsets = [0] + starts.drop(1)
|
|
57
|
+
|
|
58
|
+
# Cursor is verified evidence, not a guess; guarded against both ways it could break the offsets invariant.
|
|
59
|
+
offsets << cursor if stopped_early && cursor < shadow.length && cursor > offsets.last
|
|
60
|
+
|
|
61
|
+
offsets
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the starts of every sentence located, the cursor after the last one, and whether the walk stopped early.
|
|
65
|
+
def walk(shadow, sentences)
|
|
66
|
+
cursor = 0
|
|
67
|
+
starts = []
|
|
68
|
+
|
|
69
|
+
sentences.each do |sentence|
|
|
70
|
+
match = locate(shadow, sentence, cursor)
|
|
71
|
+
next if match == :skip
|
|
72
|
+
return [starts, cursor, true] if match.nil?
|
|
73
|
+
|
|
74
|
+
starts << match[0]
|
|
75
|
+
cursor = match[1]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
[starts, cursor, false]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# :skip for empty (an empty match would never advance the cursor); nil if a non-empty sentence isn't found.
|
|
82
|
+
def locate(shadow, sentence, cursor)
|
|
83
|
+
return :skip if sentence.empty?
|
|
84
|
+
|
|
85
|
+
start = shadow.index(sentence, cursor)
|
|
86
|
+
return nil if start.nil?
|
|
87
|
+
|
|
88
|
+
[start, start + sentence.length]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Asserts the postcondition rather than trusting construction forever: a wrong offset would silently corrupt.
|
|
92
|
+
def assert_valid_offsets(text, offsets)
|
|
93
|
+
return if offsets.first.zero? &&
|
|
94
|
+
offsets.each_cons(2).all? { |a, b| a < b } &&
|
|
95
|
+
offsets.all? { |offset| offset < text.length }
|
|
96
|
+
|
|
97
|
+
raise Error, "computed offsets #{offsets.inspect} do not start at 0, strictly increase, and stay " \
|
|
98
|
+
"within the text -- refusing to hand them back. text: #{text.inspect}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
TranslationDiff::Segmenters.registry.register(:pragmatic, TranslationDiff::Segmenters::Pragmatic)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# No runtime dependency, but deliberately conservative: every guard exists to turn a split off, never on.
|
|
2
|
+
class TranslationDiff::Segmenters::Simple
|
|
3
|
+
# CJK terminators need no trailing whitespace and no guards -- those scripts have no case or abbreviations.
|
|
4
|
+
TERMINATOR = /[.!?…]+|[。!?]+/
|
|
5
|
+
|
|
6
|
+
CJK_TERMINATOR = /\A[。!?]/
|
|
7
|
+
|
|
8
|
+
# Words that end in "." without ending a sentence, matched case-insensitively; extend for your own domain.
|
|
9
|
+
ABBREVIATIONS = %w[
|
|
10
|
+
т.е. т.д. т.п. см. рис. стр. гр. ул. г. руб. проф. акад. тыс. млн. млрд. им.
|
|
11
|
+
Mr. Mrs. Ms. Dr. Prof. St. etc. e.g. i.e. vs. approx. No. im. fig. Fig. vol. p. pp.
|
|
12
|
+
].map(&:downcase).freeze
|
|
13
|
+
|
|
14
|
+
# Strips leading punctuation like an opening quote so `"Dr. Smith` still guards on "Dr.".
|
|
15
|
+
WORD_TAIL = /[\p{L}\p{N}.]+\z/
|
|
16
|
+
|
|
17
|
+
def self.build(_config) = new
|
|
18
|
+
|
|
19
|
+
# language: is part of the shared segmenter contract but ignored here -- these rules are language-neutral.
|
|
20
|
+
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
21
|
+
def split_offsets(text, language: nil)
|
|
22
|
+
offsets = [0]
|
|
23
|
+
position = 0
|
|
24
|
+
|
|
25
|
+
while (match = TERMINATOR.match(text, position))
|
|
26
|
+
boundary = boundary_for(text, match)
|
|
27
|
+
offsets << boundary if boundary
|
|
28
|
+
position = match.end(0)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
offsets
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def boundary_for(text, match)
|
|
37
|
+
if CJK_TERMINATOR.match?(match[0])
|
|
38
|
+
cjk_boundary(text, match.end(0))
|
|
39
|
+
else
|
|
40
|
+
latin_boundary(text, match)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# If whitespace follows, it's attached to the sentence that just ended, not left as a leading gap.
|
|
45
|
+
def cjk_boundary(text, run_end)
|
|
46
|
+
boundary = skip_whitespace(text, run_end)
|
|
47
|
+
boundary if boundary < text.length
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A bare "." with nothing after it is not a sentence break, it is a string that stops mid-thought.
|
|
51
|
+
def latin_boundary(text, match)
|
|
52
|
+
run_end = match.end(0)
|
|
53
|
+
return unless whitespace?(text[run_end])
|
|
54
|
+
|
|
55
|
+
boundary = skip_whitespace(text, run_end)
|
|
56
|
+
return if boundary >= text.length
|
|
57
|
+
return if guarded?(text, match.begin(0), match[0], boundary)
|
|
58
|
+
|
|
59
|
+
boundary
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def guarded?(text, run_start, run, next_index)
|
|
63
|
+
word_before = word_before(text, run_start)
|
|
64
|
+
next_char = text[next_index]
|
|
65
|
+
|
|
66
|
+
lowercase_follows?(next_char) ||
|
|
67
|
+
abbreviation?(word_before, run) ||
|
|
68
|
+
single_letter?(word_before, run) ||
|
|
69
|
+
digits_on_both_sides?(word_before, next_char) ||
|
|
70
|
+
url_or_email?(word_before, run)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def lowercase_follows?(char)
|
|
74
|
+
!char.nil? && char.match?(/\p{Ll}/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def abbreviation?(word_before, run)
|
|
78
|
+
return false unless run.start_with?(".")
|
|
79
|
+
|
|
80
|
+
ABBREVIATIONS.include?("#{word_tail(word_before)}.".downcase)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def single_letter?(word_before, run)
|
|
84
|
+
return false unless run.start_with?(".")
|
|
85
|
+
|
|
86
|
+
tail = word_tail(word_before)
|
|
87
|
+
tail.length == 1 && tail.match?(/\p{L}/)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def digits_on_both_sides?(word_before, next_char)
|
|
91
|
+
return false if next_char.nil?
|
|
92
|
+
|
|
93
|
+
word_before[-1]&.match?(/\d/) && next_char.match?(/\d/)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def url_or_email?(word_before, run)
|
|
97
|
+
token = "#{word_before}#{run}"
|
|
98
|
+
token.include?("://") || token.include?("@")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The maximal run of non-whitespace characters immediately before the terminator.
|
|
102
|
+
def word_before(text, run_start)
|
|
103
|
+
start = run_start
|
|
104
|
+
start -= 1 while start.positive? && !whitespace?(text[start - 1])
|
|
105
|
+
text[start...run_start]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def word_tail(word_before)
|
|
109
|
+
word_before[WORD_TAIL] || ""
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def skip_whitespace(text, index)
|
|
113
|
+
index += 1 while index < text.length && whitespace?(text[index])
|
|
114
|
+
index
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def whitespace?(char)
|
|
118
|
+
!char.nil? && char.match?(/\s/)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
TranslationDiff::Segmenters.registry.register(:simple, TranslationDiff::Segmenters::Simple)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Reads and writes sentence translations under the pipeline's cache key format, without touching the caller's array.
|
|
2
|
+
class TranslationDiff::SentenceCache
|
|
3
|
+
# Its own class, so rescuing an unusable option cannot also swallow a store or provider failure.
|
|
4
|
+
class Error < TranslationDiff::Error; end
|
|
5
|
+
|
|
6
|
+
# The value types the key format can render. An allowlist: what it cannot render must raise, not be guessed at.
|
|
7
|
+
SCALARS = [String, Symbol, Numeric, TrueClass, FalseClass, NilClass].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(store:, provider:, from:, to:, options: {})
|
|
10
|
+
@store = store
|
|
11
|
+
@provider = provider
|
|
12
|
+
@from = from.downcase
|
|
13
|
+
@to = to.downcase
|
|
14
|
+
@options = options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# provider:from:to:sentence, with a digest of the per-call options wedged in as a field of its own when there are any.
|
|
18
|
+
def key(segment)
|
|
19
|
+
[@provider, @from, @to, *options_digest, Digest::MD5.hexdigest(segment.body)].join(":")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Sets a translation on every segment the store already has cached, and hands back the rest as a new array.
|
|
23
|
+
def fill(segments)
|
|
24
|
+
values = @store.read_multi(segments.map { |segment| key(segment) })
|
|
25
|
+
misses = []
|
|
26
|
+
segments.each_with_index do |segment, index|
|
|
27
|
+
value = values[index]
|
|
28
|
+
value.nil? ? misses << segment : segment.translation = value
|
|
29
|
+
end
|
|
30
|
+
misses
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Writes back only the segments that carry a translation; an untranslated segment has nothing worth caching.
|
|
34
|
+
# A store that batches gets one call; one that does not keeps the per-key contract it was written against.
|
|
35
|
+
def store(segments)
|
|
36
|
+
translated = segments.select(&:translated?)
|
|
37
|
+
return translated if translated.empty?
|
|
38
|
+
return translated.each { |segment| @store.write(key(segment), segment.translation) } if legacy_store?
|
|
39
|
+
|
|
40
|
+
@store.write_multi(translated.map { |segment| [key(segment), segment.translation] })
|
|
41
|
+
translated
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# A store written against the write-only contract, before write_multi existed, cannot be handed a batch.
|
|
47
|
+
def legacy_store? = !@store.respond_to?(:write_multi)
|
|
48
|
+
|
|
49
|
+
# No options contributes no field at all, which is the four-field key every already-warm cache is keyed on.
|
|
50
|
+
def options_digest
|
|
51
|
+
return [] if @options.empty?
|
|
52
|
+
|
|
53
|
+
[Digest::MD5.hexdigest(canonical_options)[0, 8]]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sorted on the name's string form, because a Symbol and a String key are not comparable with each other.
|
|
57
|
+
def canonical_options
|
|
58
|
+
@options.sort_by { |name, _| name.to_s }.map { |name, value| "#{name}=#{canonical(name, value)}" }.join(",")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A Hash canonicalises the way the options hash itself does, an Array its elements in order, joined the same way.
|
|
62
|
+
def canonical(name, value)
|
|
63
|
+
case value
|
|
64
|
+
when Hash then value.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}=#{canonical(name, v)}" }.join(",")
|
|
65
|
+
when Array then value.map { |element| canonical(name, element) }.join(",")
|
|
66
|
+
else scalar(name, value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A key that is silently wrong costs a caller their whole cache and tells them nothing, so refuse to build one.
|
|
71
|
+
def scalar(name, value)
|
|
72
|
+
return value.inspect if SCALARS.any? { |type| value.is_a?(type) }
|
|
73
|
+
|
|
74
|
+
raise Error, "cache option #{name} (a #{value.class}) has no stable string form"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Caches translations in the application's own database; ActiveRecord is required on first use, never at load.
|
|
2
|
+
class TranslationDiff::Stores::ActiveRecord
|
|
3
|
+
include TranslationDiff::ActiveRecord::Support
|
|
4
|
+
|
|
5
|
+
def self.build(config)
|
|
6
|
+
new(namespace: config.cache_namespace, ttl: config.cache_ttl,
|
|
7
|
+
table_name: config.cache_table_name, base: config.active_record_base,
|
|
8
|
+
prune_probability: config.cache_prune_probability)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(namespace:, ttl:, table_name:, base: nil, prune_probability: 0.0)
|
|
12
|
+
@namespace = namespace
|
|
13
|
+
@ttl = ttl
|
|
14
|
+
@table_name = table_name
|
|
15
|
+
@base = base
|
|
16
|
+
@prune_probability = prune_probability
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# One query, then the caller's order restored -- a missing or expired key is a nil in its own position.
|
|
20
|
+
def read_multi(keys)
|
|
21
|
+
return [] if keys.empty?
|
|
22
|
+
|
|
23
|
+
digests = keys.map { |key| digest(key) }
|
|
24
|
+
found = live.where(key_digest: digests).pluck(:key_digest, :translation).to_h
|
|
25
|
+
digests.map { |d| found[d] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(key, value)
|
|
29
|
+
write_multi([[key, value]])
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# One upsert for the whole batch; the unique index makes the second write of a key replace the first.
|
|
34
|
+
def write_multi(pairs)
|
|
35
|
+
return pairs if pairs.empty?
|
|
36
|
+
|
|
37
|
+
# A savepoint, not the caller's own transaction: a failed write must not abort a transaction it does not own.
|
|
38
|
+
model.transaction(requires_new: true) do
|
|
39
|
+
model.upsert_all(pairs.to_h.map { |key, value| row(key, value) }, **upsert_options(model.connection))
|
|
40
|
+
end
|
|
41
|
+
prune_sometimes
|
|
42
|
+
pairs
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
raise unless ar_error?(e)
|
|
45
|
+
|
|
46
|
+
raise redacted_error(e), cause: nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Reads never serve an expired row; deleting one is this, and it is the host's call when to run it.
|
|
50
|
+
def prune = model.where(namespace: @namespace).where(expires_at: ...Time.now.utc).delete_all
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# MySQL's adapter never answers true here and its ON DUPLICATE KEY UPDATE already targets every unique key.
|
|
55
|
+
def upsert_options(connection)
|
|
56
|
+
options = { record_timestamps: true }
|
|
57
|
+
options[:unique_by] = %i[namespace key_digest] if connection.supports_insert_conflict_target?
|
|
58
|
+
options
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# upsert_all inlines values into the statement it sends, so the adapter's own message can carry a whole row --
|
|
62
|
+
# this names the adapter's error class and the statement's shape, never the row a caller's logger already has.
|
|
63
|
+
def redacted_error(error)
|
|
64
|
+
adapter_error = error.cause&.class || error.class
|
|
65
|
+
TranslationDiff::Error.new("the cache write failed (#{adapter_error}): an upsert into " \
|
|
66
|
+
"#{@table_name}(namespace, key_digest, translation, expires_at)")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Its own message, naming the statement prune actually runs -- a failed prune is not a failed upsert.
|
|
70
|
+
def redacted_prune_error(error)
|
|
71
|
+
adapter_error = error.cause&.class || error.class
|
|
72
|
+
TranslationDiff::Error.new("the cache prune failed (#{adapter_error}): a delete from " \
|
|
73
|
+
"#{@table_name}(namespace, expires_at)")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def row(key, value)
|
|
77
|
+
{ namespace: @namespace, key_digest: digest(key), translation: value, expires_at: expires_at }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expires_at = @ttl.nil? ? nil : Time.now.utc + @ttl
|
|
81
|
+
|
|
82
|
+
# SHA256 hex is 64 characters whatever the key was, which is what makes the unique index portable.
|
|
83
|
+
def digest(key) = Digest::SHA256.hexdigest(key.to_s)
|
|
84
|
+
|
|
85
|
+
def live
|
|
86
|
+
model.where(namespace: @namespace)
|
|
87
|
+
.where(expires_at: nil).or(model.where(namespace: @namespace).where(expires_at: Time.now.utc...))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Its own savepoint, not write's -- a failing prune must not poison a transaction the caller owns either.
|
|
91
|
+
def prune_sometimes
|
|
92
|
+
return unless @prune_probability.positive? && rand < @prune_probability
|
|
93
|
+
|
|
94
|
+
model.transaction(requires_new: true) { prune }
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
raise unless ar_error?(e)
|
|
97
|
+
|
|
98
|
+
raise redacted_prune_error(e), cause: nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def active_record_feature = "the cache"
|
|
102
|
+
def active_record_component = "ActiveRecord cache store"
|
|
103
|
+
def active_record_upsert_detail = "upsert_all takes unique_by and record_timestamps there."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
TranslationDiff::Stores.register(:active_record, TranslationDiff::Stores::ActiveRecord)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# The default cache, a bounded in-process LRU. NOT thread-safe, deliberately -- set `redis_url` for that.
|
|
2
|
+
class TranslationDiff::Stores::Memory
|
|
3
|
+
def self.build(config) = new(max_size: config.cache_max_size)
|
|
4
|
+
|
|
5
|
+
def initialize(max_size:)
|
|
6
|
+
@max_size = max_size
|
|
7
|
+
@entries = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def read_multi(keys)
|
|
11
|
+
keys.map { |key| touch(key) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write(key, value)
|
|
15
|
+
@entries.delete(key)
|
|
16
|
+
@entries[key] = value
|
|
17
|
+
@entries.shift while @entries.size > @max_size
|
|
18
|
+
value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write_multi(pairs)
|
|
22
|
+
pairs.each { |key, value| write(key, value) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def touch(key)
|
|
28
|
+
return nil unless @entries.key?(key)
|
|
29
|
+
|
|
30
|
+
@entries[key] = @entries.delete(key)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
TranslationDiff::Stores.register(:memory, TranslationDiff::Stores::Memory)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
class TranslationDiff::Stores::Redis
|
|
2
|
+
ONE_WEEK = 60 * 60 * 24 * 7
|
|
3
|
+
DEFAULT_NAMESPACE = "translation-diff".freeze
|
|
4
|
+
|
|
5
|
+
def self.build(config)
|
|
6
|
+
new(config.redis_pool, timeout: config.cache_ttl, namespace: config.cache_namespace)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# `connection_pool` is duck-typed to #with; neither connection_pool nor redis-namespace is a hard dependency.
|
|
10
|
+
def initialize(connection_pool, timeout: ONE_WEEK, namespace: DEFAULT_NAMESPACE)
|
|
11
|
+
@connection_pool = connection_pool
|
|
12
|
+
@timeout = timeout
|
|
13
|
+
@namespace = namespace
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read_multi(keys)
|
|
17
|
+
redis { |redis| redis.mget(*keys) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A non-positive or nil timeout means never expires, the same rule the SQL store applies to cache_ttl.
|
|
21
|
+
def write(key, value)
|
|
22
|
+
redis { |redis| write_one(redis, key, value) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write_multi(pairs)
|
|
26
|
+
return pairs if pairs.empty?
|
|
27
|
+
|
|
28
|
+
redis { |redis| redis.pipelined { |p| pairs.each { |key, value| write_one(p, key, value) } } }
|
|
29
|
+
pairs
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :connection_pool, :timeout, :namespace
|
|
35
|
+
|
|
36
|
+
def redis
|
|
37
|
+
connection_pool.with do |redis|
|
|
38
|
+
yield ::Redis::Namespace.new(namespace, redis: redis)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def write_one(redis, key, value)
|
|
43
|
+
expiring? ? redis.setex(key, timeout, value) : redis.set(key, value)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def expiring? = timeout.is_a?(Numeric) && timeout.positive?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
TranslationDiff::Stores.register(:redis, TranslationDiff::Stores::Redis)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Cache stores, by name; assigning an object to `config.cache` bypasses this entirely.
|
|
2
|
+
module TranslationDiff::Stores
|
|
3
|
+
def self.register(name, klass) = registry.register(name, klass)
|
|
4
|
+
def self.build(name, config) = registry.build(name, config)
|
|
5
|
+
def self.registered?(name) = registry.registered?(name)
|
|
6
|
+
def self.names = registry.names
|
|
7
|
+
def self.classes = registry.classes
|
|
8
|
+
def self.registry = @registry ||= TranslationDiff::Registry.new("cache store")
|
|
9
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Shipped in the gem so a host application's own `rake` sees it -- a dependency's Rakefile is never loaded.
|
|
2
|
+
namespace :translation_diff do
|
|
3
|
+
desc "Delete expired rows from the SQL cache and rate-limit tables"
|
|
4
|
+
task :prune do
|
|
5
|
+
require "translation_diff"
|
|
6
|
+
|
|
7
|
+
cache_store = TranslationDiff.config.cache_store
|
|
8
|
+
if cache_store.respond_to?(:prune)
|
|
9
|
+
puts "pruned #{cache_store.prune} expired cache rows"
|
|
10
|
+
else
|
|
11
|
+
puts "the configured cache store (#{cache_store.class}) does not support pruning"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
rate_limiter = TranslationDiff.config.rate_limiter_instance
|
|
15
|
+
if rate_limiter.respond_to?(:prune)
|
|
16
|
+
puts "pruned #{rate_limiter.prune} expired rate-limit rows"
|
|
17
|
+
else
|
|
18
|
+
puts "the configured rate limiter (#{rate_limiter.class}) does not support pruning"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# What the pipeline asks a provider for; `from` nil means the provider should detect the source language.
|
|
2
|
+
module TranslationDiff::Translation
|
|
3
|
+
Request = Data.define(:texts, :from, :to, :options) do
|
|
4
|
+
def initialize(texts:, from:, to:, options: {})
|
|
5
|
+
super
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ::build is the one guard over every provider's parse step: a wrong count or a non-String would surface far away.
|
|
2
|
+
module TranslationDiff::Translation
|
|
3
|
+
Response = Data.define(:texts, :detected_source, :usage) do
|
|
4
|
+
def self.build(request:, texts:, detected_source: nil, usage: nil)
|
|
5
|
+
ensure_count!(request, texts)
|
|
6
|
+
ensure_strings!(texts)
|
|
7
|
+
|
|
8
|
+
# Every provider's text lands here, so it takes the same path @core did: escaped, then decoded once, so an
|
|
9
|
+
# entity a provider genuinely sent survives as the entity it is rather than the bare character it decodes to.
|
|
10
|
+
new(texts: texts.map { |text| decoded(text) }, detected_source: detected_source, usage: usage)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.decoded(text)
|
|
14
|
+
TranslationDiff::Markup.decode_entities(TranslationDiff::Markup.escape_bare_angles(text))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.ensure_count!(request, texts)
|
|
18
|
+
return if texts.size == request.texts.size
|
|
19
|
+
|
|
20
|
+
raise TranslationDiff::ResponseError,
|
|
21
|
+
"Provider returned #{texts.size} translations for #{request.texts.size} values"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The class, never the value: the value is either the customer's text or the provider's own error object.
|
|
25
|
+
def self.ensure_strings!(texts)
|
|
26
|
+
index = texts.index { |text| !text.is_a?(String) }
|
|
27
|
+
return if index.nil?
|
|
28
|
+
|
|
29
|
+
raise TranslationDiff::ResponseError,
|
|
30
|
+
"Provider returned #{texts[index].class} rather than a translation at position " \
|
|
31
|
+
"#{index} of #{texts.size}. A response can be well-formed and still carry no " \
|
|
32
|
+
"translation for one input -- a per-string failure inside a batch that answered 200."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# `billed_characters` is nil, not 0, when a provider doesn't report it; `tokens`/`model` exist for LLM providers.
|
|
2
|
+
module TranslationDiff::Translation
|
|
3
|
+
Usage = Data.define(:characters, :billed_characters, :tokens, :model) do
|
|
4
|
+
def initialize(characters:, billed_characters: nil, tokens: nil, model: nil)
|
|
5
|
+
super
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
end
|