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,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,4 @@
1
+ module TranslationDiff::Segmenters
2
+ # Segmenters, by name. `config.segmenter = :simple` resolves through here.
3
+ def self.registry = @registry ||= TranslationDiff::Registry.new("segmenter")
4
+ end
@@ -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