locallingo 0.5.0 → 0.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4296369c42c6c23ce6a15be0cc51adf0976fd6c07fd6b4514cd1a945c7ec07f9
4
- data.tar.gz: b842069155d70be0a3291f67637deb719096b3654825768265cc8007e9ae27c5
3
+ metadata.gz: b218e7422188db797e061b9cf1133b1b25a630e9e4c6237c04f0f7fa94dd2b4f
4
+ data.tar.gz: 742d0794ade8f32f9346d4a02b61554b7cb81321569c5297f9d93c3f1639f4e3
5
5
  SHA512:
6
- metadata.gz: 9f586a4ea9bd383bf58370219d99535f73f83994a2181ca7974302787a727df645f54c5e20c1465dc9f9f3e1ac586772b3bd97069b52f68c4bc0f03b99cf1422
7
- data.tar.gz: bc075cce32d4602a4c0f8672340eca7cc7edee7afd779b95fb2822d1454794d3f804816c9a50d7f095c19aa2042dc916b203556813ddaff71d918b6e87f08bb7
6
+ metadata.gz: 48e0a2e2625a03fc0d78e9b5a9884efc4ec0ee7525533f150865d7bb391c473edd96f44bbca6974a24a6e564d4d08e644a0b36403a5c8a51b28a0943d54c1d1c
7
+ data.tar.gz: 4f08c9b89ab57351c5099ff6e31c4c24fa6b33a16df8ec0b70cb0ba365e292d90ef0fbe047d4b5494b9c2c7abe0627657c6ce4b545fd4a4552150f7338fd159d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  ## [Unreleased]
8
8
 
9
9
  ### Added
10
+ - `Locallingo::Translator#translate(texts, from:, to:, style:, instructions:)`
11
+ translates a plain `{ key => text }` hash with the configured prompt,
12
+ glossary, language guide, provider and model, and returns `translations` and
13
+ `failed`. (#33)
14
+ - `Locallingo::Prompt.translation`: the translation prompt, public. `style:
15
+ :prose` keeps Markdown structure and drops the UI brevity rule;
16
+ `instructions:` is appended after the language guide. The CLI's `:ui`
17
+ prompt is unchanged. (#33)
18
+ - `Providers::RubyLLM.new(provider:, context:)` and `Translator.new(context:)`
19
+ take a `RubyLLM::Context`; with one, the global `RubyLLM.config` is never
20
+ written. (#33)
21
+ - `translate.max_batch_chars` limits the source characters per batch (a single
22
+ value over the limit is sent alone, never split); `translate.pause` sets the
23
+ sleep after each batch (default `1.0`). (#33)
24
+ - `Locallingo::Drift`: missing / outdated / manual / hand-edited decisions as a
25
+ value over the state entry shape, for strings and nested values, with a
26
+ pluggable hasher. `Manager` and the validators use it; behaviour is
27
+ unchanged. (#33)
28
+ - `require "locallingo/active_record"` adds `Locallingo::Translatable`
29
+ (`translates_content`), and `Locallingo::RecordTranslator` translates many
30
+ records in one batched call, writing each under its row lock. (#33)
10
31
  - `lingo quality --prefix yoga.` checks only the keys under a prefix, in both
11
32
  the static checks and the AI pass. A prefix that matches nothing exits `1`. (#10, #29)
12
33
  - `lingo quality --key a.b` (repeatable) checks exactly the named keys, and the
data/README.md CHANGED
@@ -146,6 +146,98 @@ Locallingo::Manager.new(config:).validate # => [violations]
146
146
  Locallingo::QualityChecker.new(config:).check(locale: "en")
147
147
  ```
148
148
 
149
+ ### Translating text that is not in locale files
150
+
151
+ `Locallingo::Translator` runs the same prompt (rules, glossary, language guide),
152
+ provider, model and batching over a plain `{ key => text }` hash:
153
+
154
+ ```ruby
155
+ require "ruby_llm" # for RubyLLM.context (Bundler.require already loads it in Rails)
156
+
157
+ translator = Locallingo::Translator.new(
158
+ config: Locallingo.configuration(root_path: Dir.pwd),
159
+ context: RubyLLM.context { |c| c.anthropic_api_key = tenant_key }, # optional, per-tenant key
160
+ pause: 0 # no sleep between batches
161
+ )
162
+
163
+ result = translator.translate(
164
+ { "title" => "Hello", "intro" => "Some **markdown**." },
165
+ from: "en", to: "de",
166
+ style: :prose, # :ui (default) or :prose (keeps Markdown, no brevity rule)
167
+ instructions: "Write in a warm, plain voice." # appended after the language guide
168
+ )
169
+ result.translations # => { "title" => "Hallo", "intro" => "Etwas **Markdown**." }
170
+ result.failed # => keys that failed after retries
171
+ ```
172
+
173
+ - With a `context:`, every call uses that `RubyLLM::Context`'s config; the
174
+ global `RubyLLM.config` is never written, so tenants can translate
175
+ concurrently.
176
+ - `MissingCredentialsError` is raised before any call when no key is found.
177
+ - `translate.max_batch_chars` (or `max_batch_chars:`) limits the source
178
+ characters per batch (a single longer value is sent alone, not split); `translate.pause` (or `pause:`) is the sleep after each
179
+ batch. Retries of failed or omitted keys keep their own backoff.
180
+ - `Locallingo::Prompt.translation(config:, from:, to:, style:, instructions:)`
181
+ returns the prompt itself.
182
+
183
+ `Locallingo::Drift` is the missing / outdated / hand-edited rule over the state
184
+ entry shape (`source_hash`, `target_hash`, `manual`), for strings or nested
185
+ values:
186
+
187
+ ```ruby
188
+ drift = Locallingo::Drift.new # hasher: any callable(String) -> String
189
+ drift.status(source:, target:, entry:) # => :missing | :outdated | :current
190
+ drift.due?(source:, target:, entry:, force: false, include_manual: false)
191
+ drift.hand_edited?(target:, entry:)
192
+ drift.record(source:, target:, manual: false) # => a new entry
193
+ ```
194
+
195
+ ### Translating records
196
+
197
+ `require "locallingo/active_record"` (ActiveRecord >= 7.1; nothing loads
198
+ ActiveRecord otherwise) adds a concern for a JSON(B) column keyed by locale
199
+ plus a JSON(B) state column:
200
+
201
+ ```ruby
202
+ class Article < ApplicationRecord
203
+ include Locallingo::Translatable
204
+
205
+ translates_content :body, # { "en" => { … }, "de" => { … } }
206
+ state: :translation_state, # { "de" => { "source_hash", "target_hash", "manual" } }
207
+ source_locale: -> { "en" }, # String/Symbol, or a callable run on the record
208
+ target_locales: -> { %w[de sv] },
209
+ translatable: ->(path, value) { true } # optional filter on flattened String leaves
210
+ end
211
+
212
+ article.translation_status(:de) # => :missing | :outdated | :current
213
+ article.translation_manual?(:de)
214
+ article.translation_due?(:de, force: false)
215
+ article.record_translation(:de, manual: false)
216
+
217
+ Locallingo::RecordTranslator.new(translator:)
218
+ .call(Article.where(id: ids), to: "de", style: :prose)
219
+ # => { translated: 3, failed: { article_id => [paths] }, skipped: [ids] }
220
+ ```
221
+
222
+ - Saving a record runs `mark_hand_edits`: a target locale a person changed is
223
+ flagged `manual` and never machine-overwritten (`include_manual: true`
224
+ overrides, keeping the flag). That includes a target locale saved for the
225
+ first time, e.g. a source copy used as a placeholder at `create!`: leave
226
+ untranslated locales out of the column instead. A cleared locale is not
227
+ flagged and is due again, unless it was already `manual`: the flag stays, so
228
+ retranslating it needs `include_manual: true`.
229
+ - `RecordTranslator` sends one batched translation per source locale across all
230
+ due records, then writes each record under `record.with_lock`. A record whose
231
+ source changed, or whose target became manual, while the model answered is
232
+ skipped (`skipped:`) and left for the next run, as is a due record with no
233
+ translatable text.
234
+ - Nested values keep their shape; leaves the `translatable:` filter rejects are
235
+ copied from the source. A record with any segment that failed after retries
236
+ is not written at all: its paths are reported in `failed:`, and it stays due
237
+ for the next run (your fallback to the source locale applies meanwhile).
238
+ - Writes use `update_columns`; pass `write: ->(record, attributes) { … }` to
239
+ bump a lock version or run validations.
240
+
149
241
  ## License
150
242
 
151
243
  MIT © Mikael Henriksson
@@ -26,6 +26,12 @@ defaults:
26
26
  translate:
27
27
  model: gpt-5-mini
28
28
  batch_size: 20
29
+ # Optional cap on the characters of source text per batch (a batch closes at
30
+ # batch_size keys or max_batch_chars, whichever comes first). Useful for long
31
+ # prose; a single oversized value is still sent alone. null = keys only.
32
+ max_batch_chars: null
33
+ # Seconds to sleep after each batch (rate-limit courtesy). 0 disables it.
34
+ pause: 1.0
29
35
 
30
36
  quality:
31
37
  model: gpt-4o-mini
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ require_relative "../locallingo"
7
+ require_relative "content_leaves"
8
+
9
+ module Locallingo
10
+ # Opt-in ActiveRecord concern (`require "locallingo/active_record"`) for the
11
+ # common shape: a JSON(B) column keyed by locale plus a JSON(B) state column
12
+ # holding one Drift entry per target locale. Supports ActiveRecord >= 7.1.
13
+ #
14
+ # class Article < ApplicationRecord
15
+ # include Locallingo::Translatable
16
+ #
17
+ # translates_content :body, # { "en" => { … }, "de" => { … } }
18
+ # state: :translation_state, # { "de" => { "source_hash", "target_hash", "manual" } }
19
+ # source_locale: -> { "en" }, # String/Symbol, or a callable run on the record
20
+ # target_locales: -> { %w[de sv] },
21
+ # translatable: ->(path, value) { true } # optional filter on flattened String leaves
22
+ # end
23
+ #
24
+ # A `before_save` callback (#mark_hand_edits) flags target locales a person
25
+ # changed as manual, so Locallingo::RecordTranslator never overwrites them.
26
+ module Translatable
27
+ extend ActiveSupport::Concern
28
+
29
+ Definition = Data.define(:column, :state, :source_locale, :target_locales, :translatable, :drift)
30
+
31
+ included do
32
+ class_attribute :locallingo_translatable, instance_writer: false
33
+ end
34
+
35
+ class_methods do
36
+ def translates_content(column, state:, source_locale:, target_locales:, translatable: nil, hasher: nil)
37
+ self.locallingo_translatable = Definition.new(
38
+ column: column.to_s, state: state.to_s, source_locale:, target_locales:,
39
+ translatable: translatable || ->(_path, _value) { true },
40
+ drift: hasher ? Drift.new(hasher:) : Drift.new
41
+ )
42
+ before_save :mark_hand_edits
43
+ end
44
+ end
45
+
46
+ def translation_source_locale = resolve_translation_option(locallingo_translatable.source_locale).to_s
47
+
48
+ def translation_target_locales
49
+ Array(resolve_translation_option(locallingo_translatable.target_locales)).map(&:to_s)
50
+ end
51
+
52
+ def translation_source = translation_content[translation_source_locale]
53
+ def translation_target(locale) = translation_content[locale.to_s]
54
+ def translation_entry(locale) = translation_states[locale.to_s]
55
+ def translation_source_digest = translation_drift.digest(translation_source)
56
+
57
+ def translation_status(locale)
58
+ translation_drift.status(source: translation_source, target: translation_target(locale),
59
+ entry: translation_entry(locale))
60
+ end
61
+
62
+ def translation_manual?(locale) = translation_drift.manual?(translation_entry(locale))
63
+
64
+ def translation_due?(locale, force: false, include_manual: false)
65
+ translation_drift.due?(source: translation_source, target: translation_target(locale),
66
+ entry: translation_entry(locale), force:, include_manual:)
67
+ end
68
+
69
+ # Records the current source and target of +locale+ as its state entry
70
+ # (assigned, not saved).
71
+ def record_translation(locale, manual: false)
72
+ entry = translation_drift.record(source: translation_source, target: translation_target(locale), manual:)
73
+ self[locallingo_translatable.state] = translation_states.merge(locale.to_s => entry)
74
+ end
75
+
76
+ # Flags every target locale a person changed as manual: a recorded target
77
+ # that no longer matches its target_hash, or an untracked locale written in
78
+ # this save. A cleared target is not flagged — it is missing and due again.
79
+ def mark_hand_edits
80
+ (translation_target_locales - [translation_source_locale]).each do |locale|
81
+ target = translation_target(locale)
82
+ next if translation_drift.blank?(target)
83
+
84
+ entry = translation_entry(locale)
85
+ next unless translation_drift.hand_edited?(target:, entry:) || (entry.nil? && translation_written?(locale))
86
+
87
+ # A recorded source_hash is kept: a hand edit does not make an
88
+ # outdated translation current.
89
+ manual = translation_drift.record(source: translation_source, target:, manual: true)
90
+ manual["source_hash"] = entry["source_hash"] if entry&.key?("source_hash")
91
+ self[locallingo_translatable.state] = translation_states.merge(locale => manual)
92
+ end
93
+ end
94
+
95
+ # The source's translatable String leaves, flattened (`steps[1].text`,
96
+ # `table[0][1]`). Unlike KeyFlattener, strings in nested arrays count.
97
+ def translation_segments
98
+ translation_leaves.filter_map do |path, (_, value)|
99
+ next unless value.is_a?(String) && !value.strip.empty?
100
+
101
+ [path, value] if locallingo_translatable.translatable.call(path, value)
102
+ end.to_h
103
+ end
104
+
105
+ # The target for translated +segments+ (`path => text`): a deep copy of the
106
+ # source with each translated leaf set in place, so every other value and
107
+ # container (numbers, nested lists, empty ones) keeps its place.
108
+ def build_translation_target(segments)
109
+ target = JSON.parse(JSON.generate(translation_source || {}))
110
+ leaves = translation_leaves
111
+ segments.each do |path, text|
112
+ *parents, last = leaves.fetch(path).first
113
+ (parents.empty? ? target : target.dig(*parents))[last] = text
114
+ end
115
+ target
116
+ end
117
+
118
+ # Column attributes that write +target+ for +locale+ plus its state entry.
119
+ def translation_attributes(locale, target, manual: false)
120
+ locale = locale.to_s
121
+ entry = translation_drift.record(source: translation_source, target:, manual:)
122
+ {
123
+ locallingo_translatable.column => translation_content.merge(locale => target),
124
+ locallingo_translatable.state => translation_states.merge(locale => entry)
125
+ }
126
+ end
127
+
128
+ def translation_drift = locallingo_translatable.drift
129
+
130
+ private
131
+
132
+ def translation_leaves = ContentLeaves.call(translation_source || {}, label: "#{self.class.name}##{id}")
133
+
134
+ def translation_content = self[locallingo_translatable.column] || {}
135
+ def translation_states = self[locallingo_translatable.state] || {}
136
+
137
+ def translation_written?(locale)
138
+ column = locallingo_translatable.column
139
+ return false unless will_save_change_to_attribute?(column)
140
+
141
+ (attribute_in_database(column) || {})[locale] != translation_target(locale)
142
+ end
143
+
144
+ def resolve_translation_option(option)
145
+ option.respond_to?(:call) ? instance_exec(&option) : option
146
+ end
147
+ end
148
+ end
@@ -17,10 +17,16 @@ module Locallingo
17
17
  BASE_SLEEP_DURATION = 1.0
18
18
 
19
19
  # `log` is called as `log.call(message, level: :info)`.
20
- def initialize(provider:, model:, batch_size:, log:)
20
+ #
21
+ # A batch closes at `batch_size` keys or `max_batch_chars` characters of
22
+ # source text, whichever comes first; a single oversized value goes alone.
23
+ # `pause` is the sleep after each batch (0 in a background job).
24
+ def initialize(provider:, model:, batch_size:, log:, max_batch_chars: nil, pause: BASE_SLEEP_DURATION)
21
25
  @provider = provider
22
26
  @model = model
23
27
  @batch_size = batch_size
28
+ @max_batch_chars = max_batch_chars
29
+ @pause = pause
24
30
  @log = log
25
31
  end
26
32
 
@@ -66,7 +72,7 @@ module Locallingo
66
72
  translations = {}
67
73
  failed = []
68
74
 
69
- keys.each_slice(@batch_size) do |batch|
75
+ batches(keys).each do |batch|
70
76
  result = translate_batch(batch.to_h { |key| [key, @source[key]] })
71
77
  batch.each do |key|
72
78
  if result.key?(key) && !result[key].to_s.empty?
@@ -75,12 +81,29 @@ module Locallingo
75
81
  failed << key
76
82
  end
77
83
  end
78
- sleep(BASE_SLEEP_DURATION)
84
+ sleep(@pause) if @pause.positive?
79
85
  end
80
86
 
81
87
  [translations, failed]
82
88
  end
83
89
 
90
+ def batches(keys)
91
+ return keys.each_slice(@batch_size).to_a unless @max_batch_chars
92
+
93
+ keys.each_with_object([[]]) do |key, groups|
94
+ groups << [] if full?(groups.last, key)
95
+ groups.last << key
96
+ end
97
+ end
98
+
99
+ def full?(batch, key)
100
+ return false if batch.empty?
101
+
102
+ batch.size >= @batch_size || chars(batch) + chars([key]) > @max_batch_chars
103
+ end
104
+
105
+ def chars(batch) = batch.sum { |key| @source[key].to_s.length }
106
+
84
107
  def translate_batch(payload)
85
108
  return {} if payload.empty?
86
109
 
@@ -54,6 +54,8 @@ module Locallingo
54
54
  def provider = data.fetch("provider").to_sym
55
55
  def translate_model = dig("translate", "model")
56
56
  def batch_size = dig("translate", "batch_size")
57
+ def max_batch_chars = dig("translate", "max_batch_chars")
58
+ def pause = dig("translate", "pause") || 1.0
57
59
  def quality_model = dig("quality", "model")
58
60
  def quality_sample_size = dig("quality", "sample_size")
59
61
  def british_spellings? = !!dig("quality", "british_spellings")
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Locallingo
4
+ # Walks a nested Hash/Array value to its leaves, keyed by a KeyFlattener-style
5
+ # path (`steps[1].text`, `table[0][1]`). Unlike KeyFlattener, strings inside
6
+ # nested arrays count, and each leaf carries the Hash keys and Array indices
7
+ # leading to it, so a value can be set back in place without parsing paths.
8
+ module ContentLeaves
9
+ module_function
10
+
11
+ # `path => [parts, value]`. Two leaves with the same path (a literal "a.b"
12
+ # key next to a → b) raise: one would be silently dropped from translation.
13
+ def call(value, label: "content")
14
+ collect(value, [], {}, label)
15
+ end
16
+
17
+ def collect(value, parts, leaves, label)
18
+ case value
19
+ when Hash then value.each { |key, item| collect(item, parts + [key.to_s], leaves, label) }
20
+ when Array then value.each_with_index { |item, index| collect(item, parts + [index], leaves, label) }
21
+ else
22
+ path = path(parts)
23
+ raise Error, "Ambiguous translation path #{path.inspect} in #{label}" if leaves.key?(path)
24
+
25
+ leaves[path] = [parts, value]
26
+ end
27
+ leaves
28
+ end
29
+
30
+ def path(parts)
31
+ parts.each_with_index.map do |part, index|
32
+ next "[#{part}]" if part.is_a?(Integer)
33
+
34
+ index.zero? ? part : ".#{part}"
35
+ end.join
36
+ end
37
+
38
+ private_class_method :collect, :path
39
+ end
40
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "state_store"
5
+
6
+ module Locallingo
7
+ # Missing / outdated / hand-edited decisions over one value and its state
8
+ # entry — the rule locale files and database records share.
9
+ #
10
+ # An entry is the shape StateStore writes:
11
+ # { "source_hash" => …, "target_hash" => …, "manual" => true }
12
+ #
13
+ # Values may be a String or a nested Hash/Array of strings. A String hashes
14
+ # raw (exactly like StateStore, so existing state files stay valid); nested
15
+ # values hash over a canonical JSON form with sorted keys, so a record can
16
+ # track one entry per field or one per whole value.
17
+ class Drift
18
+ def initialize(hasher: StateStore.method(:hash))
19
+ @hasher = hasher
20
+ end
21
+
22
+ def digest(value)
23
+ case value
24
+ when Hash, Array then @hasher.call(JSON.generate(canonical(value)))
25
+ else @hasher.call(value.to_s)
26
+ end
27
+ end
28
+
29
+ # A new entry for a target translated (or written by hand) from +source+.
30
+ def record(source:, target:, manual: false)
31
+ entry = { "source_hash" => digest(source), "target_hash" => digest(target) }
32
+ entry["manual"] = true if manual
33
+ entry
34
+ end
35
+
36
+ # :missing (blank target), :outdated (source changed since the entry was
37
+ # recorded) or :current. The manual flag is not considered here; #due?
38
+ # applies it.
39
+ def status(source:, target:, entry:)
40
+ return :missing if blank?(target)
41
+ return :outdated if outdated?(source:, entry:)
42
+
43
+ :current
44
+ end
45
+
46
+ # The source changed since +entry+ recorded it. An untracked value (no
47
+ # entry or no source_hash) is not outdated.
48
+ def outdated?(source:, entry:)
49
+ stored = entry.is_a?(Hash) ? entry["source_hash"] : nil
50
+ !stored.nil? && stored != digest(source)
51
+ end
52
+
53
+ # A person wrote the target: it must not be machine-overwritten.
54
+ def manual?(entry) = entry.is_a?(Hash) && entry["manual"] ? true : false
55
+
56
+ # The target changed since it was recorded and is not yet flagged manual.
57
+ def hand_edited?(target:, entry:)
58
+ return false unless entry.is_a?(Hash) && !manual?(entry)
59
+
60
+ stored = entry["target_hash"]
61
+ !stored.nil? && stored != digest(target)
62
+ end
63
+
64
+ # Whether the target should be (re)translated: missing or outdated, every
65
+ # value with +force+, and never a manual one unless +include_manual+.
66
+ def due?(source:, target:, entry:, force: false, include_manual: false)
67
+ return false if blank?(source)
68
+ return false if manual?(entry) && !include_manual
69
+ return true if force
70
+
71
+ status(source:, target:, entry:) != :current
72
+ end
73
+
74
+ # Empty, whitespace-only, or a Hash/Array with only blank leaves.
75
+ def blank?(value)
76
+ case value
77
+ when Hash then value.values.all? { |item| blank?(item) }
78
+ when Array then value.all? { |item| blank?(item) }
79
+ else value.to_s.strip.empty?
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def canonical(value)
86
+ case value
87
+ when Hash then value.map { |key, item| [key.to_s, canonical(item)] }.sort_by(&:first).to_h
88
+ when Array then value.map { |item| canonical(item) }
89
+ else value
90
+ end
91
+ end
92
+ end
93
+ end
@@ -8,8 +8,10 @@ require "fileutils"
8
8
  require_relative "configuration"
9
9
  require_relative "key_flattener"
10
10
  require_relative "state_store"
11
+ require_relative "drift"
11
12
  require_relative "providers/ruby_llm"
12
13
  require_relative "batch_translator"
14
+ require_relative "prompt"
13
15
  require_relative "validators/missing"
14
16
  require_relative "validators/outdated"
15
17
  require_relative "validators/duplicate_values"
@@ -39,6 +41,7 @@ module Locallingo
39
41
  @verbose = verbose
40
42
  @cli_name = cli_name
41
43
  @state = StateStore.new(@config.state_dir)
44
+ @drift = Drift.new
42
45
  @provider = Providers::RubyLLM.new(provider: @config.provider)
43
46
  @logger = logger || build_logger
44
47
  end
@@ -127,11 +130,7 @@ module Locallingo
127
130
 
128
131
  plans.each_with_object({}) do |(target_locale, target, locale_state, accepted), results|
129
132
  accepted.each do |key|
130
- locale_state[key] = {
131
- "source_hash" => @state.hash(source[key]),
132
- "target_hash" => @state.hash(target[key]),
133
- "manual" => true
134
- }
133
+ locale_state[key] = @drift.record(source: source[key], target: target[key], manual: true)
135
134
  end
136
135
  @state.save(target_locale, locale_state) unless dry_run
137
136
  results[target_locale] = accepted
@@ -179,7 +178,8 @@ module Locallingo
179
178
 
180
179
  def batch_translator
181
180
  @batch_translator ||= BatchTranslator.new(
182
- provider: @provider, model: config.translate_model, batch_size: config.batch_size, log: method(:log)
181
+ provider: @provider, model: config.translate_model, batch_size: config.batch_size,
182
+ max_batch_chars: config.max_batch_chars, pause: config.pause, log: method(:log)
183
183
  )
184
184
  end
185
185
 
@@ -208,10 +208,7 @@ module Locallingo
208
208
  return target.keys.select { |key| source.key?(key) } if all
209
209
 
210
210
  target.keys.select do |key|
211
- entry = locale_state[key]
212
- next false unless source.key?(key) && entry.is_a?(Hash) && !entry["manual"]
213
-
214
- entry["target_hash"] && entry["target_hash"] != @state.hash(target[key])
211
+ source.key?(key) && @drift.hand_edited?(target: target[key], entry: locale_state[key])
215
212
  end
216
213
  end
217
214
 
@@ -261,47 +258,10 @@ module Locallingo
261
258
  failed
262
259
  end
263
260
 
264
- def translation_prompt(locale)
265
- language = config.language_name(locale)
266
- <<~PROMPT
267
- Translate the following texts from #{config.language_name(config.source_locale)} into natural, idiomatic #{language}.
268
- This is for #{config.context}.
269
-
270
- ## Idiomatic Translation
271
- - Translate the meaning, not word for word: write what a native #{language} speaker would actually say in this situation
272
- - Use the phrasing, word order and UI conventions that are standard in #{language} software, even when they differ from the source
273
- - Replace source-language idioms and set phrases with their natural #{language} equivalent instead of translating them literally
274
- - Avoid calques and anglicisms when an established #{language} expression exists
275
- - Never add, drop or change information to make a sentence sound natural
276
- - Terminology and any language guide below take precedence over these style rules
277
-
278
- ## General Rules
279
- - Preserve placeholders like #{config.placeholder_style} exactly as they appear
280
- - Preserve HTML tags if present
281
- - Keep translations concise - UI space is limited
282
- - Escape every double quote (") inside a translated value as \\" so the reply stays valid JSON
283
- - Keep typographic quotes (“ ” „ ‚ « ») exactly as they appear in the source
284
- #{glossary_section}#{language_guide_section(locale)}
285
- Return ONLY a raw JSON object mapping each input key to its translation,
286
- with no surrounding prose and no markdown code fences:
287
- {"key1": "translated1", "key2": "translated2"}
288
- PROMPT
289
- end
290
-
291
- def glossary_section
292
- return "" if config.glossary.empty?
293
-
294
- lines = config.glossary.map { |term, meaning| " - \"#{term}\" = #{meaning}" }
295
- "\n## Terminology\n#{lines.join("\n")}\n"
296
- end
297
-
298
- def language_guide_section(locale)
299
- guide = config.language_guide(locale)
300
- guide.empty? ? "" : "\n#{guide}\n"
301
- end
261
+ def translation_prompt(locale) = Prompt.translation(config:, from: config.source_locale, to: locale)
302
262
 
303
263
  def determine_keys_to_translate(source, target, locale_state, force:, force_keys:, exceptions:)
304
- manual_keys = locale_state.select { |_k, v| v.is_a?(Hash) && v["manual"] }.keys.to_set
264
+ manual_keys = locale_state.select { |_key, entry| @drift.manual?(entry) }.keys.to_set
305
265
  return source.keys - manual_keys.to_a if force
306
266
  return force_keys & source.keys if force_keys.any?
307
267
 
@@ -314,7 +274,7 @@ module Locallingo
314
274
  # Drops manual keys from the named force_keys (with a loud warning) unless
315
275
  # the caller opted in to overwriting them.
316
276
  def resolve_force_keys(source, force_keys, locale_state, include_manual:, locale:)
317
- manual = (force_keys & source.keys).select { |key| locale_state[key].is_a?(Hash) && locale_state[key]["manual"] }
277
+ manual = (force_keys & source.keys).select { |key| @drift.manual?(locale_state[key]) }
318
278
  return force_keys if manual.empty?
319
279
 
320
280
  if include_manual
@@ -335,15 +295,11 @@ module Locallingo
335
295
  translations.each_key do |key|
336
296
  next unless source[key]
337
297
 
338
- existing = locale_state[key]
339
- entry = {
340
- "source_hash" => @state.hash(source[key]),
341
- "target_hash" => @state.hash(translations[key])
342
- }
343
298
  # A force-keyed retranslation overwrites a manual value only with
344
299
  # include_manual, and even then the protection flag must survive.
345
- entry["manual"] = true if existing.is_a?(Hash) && existing["manual"]
346
- locale_state[key] = entry
300
+ locale_state[key] = @drift.record(
301
+ source: source[key], target: translations[key], manual: @drift.manual?(locale_state[key])
302
+ )
347
303
  end
348
304
  end
349
305
 
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Locallingo
4
+ # Builds the translation system prompt: idiomatic-translation and formatting
5
+ # rules, the configured glossary and per-language guide, plus optional caller
6
+ # instructions.
7
+ #
8
+ # Styles:
9
+ # - `:ui` — short interface strings (the CLI's prompt, pinned by a spec)
10
+ # - `:prose` — long-form content: Markdown structure is kept and there is no
11
+ # brevity rule
12
+ module Prompt
13
+ STYLES = %i[ui prose].freeze
14
+
15
+ BREVITY_RULE = "- Keep translations concise - UI space is limited\n"
16
+
17
+ MARKDOWN_RULES = <<~RULES
18
+ - Keep the Markdown structure: headings, lists, emphasis, links and line breaks stay where they are
19
+ - Translate link text, never the link target (URL)
20
+ - Never merge or split values: every key gets exactly one translated value
21
+ RULES
22
+
23
+ module_function
24
+
25
+ def translation(config:, from:, to:, style: :ui, instructions: nil)
26
+ raise Error, "Unknown prompt style #{style.inspect} (expected one of #{STYLES.join(", ")})" unless
27
+ STYLES.include?(style.to_s.to_sym)
28
+
29
+ language = config.language_name(to)
30
+ <<~PROMPT
31
+ Translate the following texts from #{config.language_name(from)} into natural, idiomatic #{language}.
32
+ This is for #{config.context}.
33
+
34
+ ## Idiomatic Translation
35
+ - Translate the meaning, not word for word: write what a native #{language} speaker would actually say in this situation
36
+ - Use the phrasing, word order and UI conventions that are standard in #{language} software, even when they differ from the source
37
+ - Replace source-language idioms and set phrases with their natural #{language} equivalent instead of translating them literally
38
+ - Avoid calques and anglicisms when an established #{language} expression exists
39
+ - Never add, drop or change information to make a sentence sound natural
40
+ - Terminology and any language guide below take precedence over these style rules
41
+
42
+ ## General Rules
43
+ - Preserve placeholders like #{config.placeholder_style} exactly as they appear
44
+ - Preserve HTML tags if present
45
+ #{style.to_sym == :prose ? MARKDOWN_RULES : BREVITY_RULE}- Escape every double quote (") inside a translated value as \\" so the reply stays valid JSON
46
+ - Keep typographic quotes (“ ” „ ‚ « ») exactly as they appear in the source
47
+ #{glossary_section(config)}#{language_guide_section(config, to)}#{instructions_section(instructions)}
48
+ Return ONLY a raw JSON object mapping each input key to its translation,
49
+ with no surrounding prose and no markdown code fences:
50
+ {"key1": "translated1", "key2": "translated2"}
51
+ PROMPT
52
+ end
53
+
54
+ def glossary_section(config)
55
+ return "" if config.glossary.empty?
56
+
57
+ lines = config.glossary.map { |term, meaning| " - \"#{term}\" = #{meaning}" }
58
+ "\n## Terminology\n#{lines.join("\n")}\n"
59
+ end
60
+
61
+ def language_guide_section(config, locale)
62
+ guide = config.language_guide(locale)
63
+ guide.empty? ? "" : "\n#{guide}\n"
64
+ end
65
+
66
+ def instructions_section(instructions)
67
+ text = instructions.to_s.strip
68
+ text.empty? ? "" : "\n## Additional Instructions\n#{text}\n"
69
+ end
70
+
71
+ private_class_method :glossary_section, :language_guide_section, :instructions_section
72
+ end
73
+ end
@@ -18,6 +18,11 @@ module Locallingo
18
18
  # prompt is built once per locale and reused by every batch, so providers
19
19
  # that support caching (Anthropic, OpenAI-compatible) bill the repeated
20
20
  # prefix at the cached rate.
21
+ #
22
+ # Given a `RubyLLM::Context` (`RubyLLM.context { |c| c.anthropic_api_key = … }`),
23
+ # every chat runs on that context's own configuration: the global
24
+ # `RubyLLM.config` is never written, so translators with different keys
25
+ # (one per tenant) can run concurrently.
21
26
  class RubyLLM
22
27
  # Maps a RubyLLM provider symbol to the ENV var whose presence indicates
23
28
  # credentials are available, so we can fail fast with a clear message
@@ -30,10 +35,11 @@ module Locallingo
30
35
  openrouter: "OPENROUTER_API_KEY"
31
36
  }.freeze
32
37
 
33
- attr_reader :provider
38
+ attr_reader :provider, :context
34
39
 
35
- def initialize(provider:)
40
+ def initialize(provider:, context: nil)
36
41
  @provider = provider.to_sym
42
+ @context = context
37
43
  end
38
44
 
39
45
  # True when a key for the configured provider is found in any source
@@ -50,6 +56,12 @@ module Locallingo
50
56
  def ensure_credentials!
51
57
  return if credentials?
52
58
 
59
+ if context
60
+ raise MissingCredentialsError,
61
+ "No credentials for provider #{provider.inspect}: the given RubyLLM context " \
62
+ "has no #{key_setting}."
63
+ end
64
+
53
65
  raise MissingCredentialsError,
54
66
  "No credentials for provider #{provider.inspect}. " \
55
67
  "Set #{CREDENTIAL_ENV[provider]} in ENV, call " \
@@ -61,20 +73,21 @@ module Locallingo
61
73
  # the model and return the parsed JSON object as a Hash.
62
74
  def chat(model:, instructions:, payload:)
63
75
  require "ruby_llm"
64
- configure_credentials!
65
-
66
- conversation = ::RubyLLM.chat(
67
- model:,
68
- provider:,
69
- assume_model_exists: true
70
- ).with_instructions(instructions, cache_until_here: true)
71
76
 
77
+ conversation = new_chat(model).with_instructions(instructions, cache_until_here: true)
72
78
  response = conversation.ask(JSON.pretty_generate(payload))
73
79
  JsonExtraction.extract_object(response.content)
74
80
  end
75
81
 
76
82
  private
77
83
 
84
+ def new_chat(model)
85
+ return context.chat(model:, provider:, assume_model_exists: true) if context
86
+
87
+ configure_credentials!
88
+ ::RubyLLM.chat(model:, provider:, assume_model_exists: true)
89
+ end
90
+
78
91
  # RubyLLM does not read provider API keys from ENV on its own, so a
79
92
  # standalone CLI run (no Rails initializer to call RubyLLM.configure)
80
93
  # would raise "Missing configuration for <provider>". Push the resolved
@@ -98,9 +111,18 @@ module Locallingo
98
111
  # Key resolution across sources, in precedence order. Used by
99
112
  # #credentials? as a fail-fast check before any network call.
100
113
  def resolved_api_key
114
+ return context_api_key if context
115
+
101
116
  settings_api_key || host_configured_api_key || env_api_key
102
117
  end
103
118
 
119
+ # With a context, its own config is the only source: the chat never
120
+ # reads the settings, the global config or ENV.
121
+ def context_api_key
122
+ config = context.config
123
+ config.respond_to?(key_setting) ? presence(config.public_send(key_setting)) : nil
124
+ end
125
+
104
126
  def settings_api_key
105
127
  Locallingo.settings.api_key_for(provider)
106
128
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Locallingo
4
+ # Translates records that include Locallingo::Translatable (see
5
+ # `locallingo/active_record`) into one target locale.
6
+ #
7
+ # Locallingo::RecordTranslator.new(translator:)
8
+ # .call(Article.where(id: ids), to: "de", style: :prose)
9
+ # # => { translated: 3, failed: { article_id => [paths] }, skipped: [ids] }
10
+ #
11
+ # The due records' segments (keys prefixed by record id) go out as one
12
+ # batched translation per source locale. Each record is then written under
13
+ # its row lock, after re-checking that its source is unchanged and the
14
+ # target is still due: a source edited or a target hand-written while the
15
+ # model answered is skipped and left for the next run. A record with any
16
+ # failed segment is not written; it stays due and its paths are reported.
17
+ #
18
+ # Records must be persisted and of one model (ids are the key prefix). This
19
+ # class never loads ActiveRecord itself; records only need the concern.
20
+ class RecordTranslator
21
+ DEFAULT_WRITE = ->(record, attributes) { record.update_columns(attributes) }
22
+
23
+ # +write+ is called as `write.call(record, attributes)` inside the lock,
24
+ # e.g. to bump a lock version or run validations (`record.update!(attributes)`).
25
+ def initialize(translator:, write: DEFAULT_WRITE)
26
+ @translator = translator
27
+ @write = write
28
+ end
29
+
30
+ def call(records, to:, style: :ui, instructions: nil, force: false, include_manual: false)
31
+ @locale = to.to_s
32
+ @force = force
33
+ @include_manual = include_manual
34
+ @result = { translated: 0, failed: {}, skipped: [] }
35
+
36
+ due, empty = records.select { |record| due?(record) }.partition { |record| record.translation_segments.any? }
37
+ @result[:skipped].concat(empty.map(&:id))
38
+ due.group_by(&:translation_source_locale).each do |from, group|
39
+ translate_group(group, from:, style:, instructions:)
40
+ end
41
+ @result
42
+ end
43
+
44
+ private
45
+
46
+ def due?(record) = record.translation_due?(@locale, force: @force, include_manual: @include_manual)
47
+
48
+ def translate_group(records, from:, style:, instructions:)
49
+ digests = records.to_h { |record| [record, record.translation_source_digest] }
50
+ owners = {}
51
+ segments = records.each_with_object({}) do |record, all|
52
+ record.translation_segments.each do |path, text|
53
+ key = "#{record.id}.#{path}"
54
+ raise Error, "Segment key #{key} is ambiguous across records (an id containing \".\"?)" if owners.key?(key)
55
+
56
+ owners[key] = [record, path]
57
+ all[key] = text
58
+ end
59
+ end
60
+
61
+ result = @translator.translate(segments, from:, to: @locale, style:, instructions:)
62
+ records.each do |record|
63
+ translations = own(result.translations, owners, record).to_h
64
+ failed = own(result.failed.to_h { |key| [key, nil] }, owners, record).map(&:first)
65
+ @result[:failed][record.id] = failed if failed.any?
66
+ # All or nothing per record: one state entry covers the whole locale,
67
+ # so a partial write would read as current with values missing.
68
+ write(record, translations, digests[record]) if failed.empty? && translations.any?
69
+ end
70
+ end
71
+
72
+ # The +record+'s pairs from +pairs+, keyed by its own segment path.
73
+ def own(pairs, owners, record)
74
+ pairs.filter_map do |key, value|
75
+ owner, path = owners[key]
76
+ [path, value] if owner.equal?(record)
77
+ end
78
+ end
79
+
80
+ def write(record, translations, digest)
81
+ record.with_lock do
82
+ next @result[:skipped] << record.id unless record.translation_source_digest == digest && due?(record)
83
+
84
+ manual = record.translation_manual?(@locale)
85
+ target = record.build_translation_target(translations)
86
+ @write.call(record, record.translation_attributes(@locale, target, manual:))
87
+ @result[:translated] += 1
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "configuration"
6
+ require_relative "prompt"
7
+ require_relative "providers/ruby_llm"
8
+ require_relative "batch_translator"
9
+
10
+ module Locallingo
11
+ # The public, file-agnostic entry point: translates a plain `{ key => text }`
12
+ # hash between two locales with the configured rules, glossary, language
13
+ # guide, provider and model.
14
+ #
15
+ # translator = Locallingo::Translator.new(
16
+ # config: Locallingo.configuration,
17
+ # context: RubyLLM.context { |c| c.anthropic_api_key = tenant_key } # optional
18
+ # )
19
+ # result = translator.translate({ "title" => "Hello" }, from: "en", to: "de", style: :prose)
20
+ # result.translations # => { "title" => "Hallo" }
21
+ # result.failed # => []
22
+ #
23
+ # Batching, retries, batch halving and omitted-key rounds come from
24
+ # BatchTranslator. Blank texts are not sent.
25
+ class Translator
26
+ Result = Data.define(:translations, :failed)
27
+
28
+ attr_reader :config
29
+
30
+ def initialize(config: Locallingo.configuration, context: nil, model: nil, logger: nil,
31
+ max_batch_chars: nil, pause: nil)
32
+ @config = config
33
+ @provider = Providers::RubyLLM.new(provider: config.provider, context:)
34
+ @model = model || config.translate_model
35
+ @logger = logger || Logger.new($stderr, level: Logger::WARN)
36
+ @max_batch_chars = max_batch_chars || config.max_batch_chars
37
+ @pause = pause || config.pause
38
+ end
39
+
40
+ # Raises MissingCredentialsError before any call when no key is available.
41
+ def translate(texts, from:, to:, style: :ui, instructions: nil)
42
+ @provider.ensure_credentials!
43
+
44
+ source = stringify_keys(texts).reject { |_key, text| text.to_s.strip.empty? }
45
+ return Result.new(translations: {}, failed: []) if source.empty?
46
+
47
+ prompt = Prompt.translation(config:, from:, to:, style:, instructions:)
48
+ translated, failed = batch_translator.call(source, source.keys, instructions: prompt)
49
+ Result.new(translations: translated.slice(*source.keys).except(*failed), failed:)
50
+ end
51
+
52
+ private
53
+
54
+ def stringify_keys(texts)
55
+ texts.each_with_object({}) do |(key, text), source|
56
+ raise Error, "Duplicate key #{key.to_s.inspect} (e.g. :#{key} and \"#{key}\")" if source.key?(key.to_s)
57
+
58
+ source[key.to_s] = text
59
+ end
60
+ end
61
+
62
+ def batch_translator
63
+ @batch_translator ||= BatchTranslator.new(
64
+ provider: @provider, model: @model, batch_size: config.batch_size,
65
+ max_batch_chars: @max_batch_chars, pause: @pause,
66
+ log: ->(message, level: :info) { @logger.public_send(level, message) }
67
+ )
68
+ end
69
+ end
70
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../state_store"
3
+ require_relative "../drift"
4
4
 
5
5
  module Locallingo
6
6
  module Validators
@@ -12,19 +12,15 @@ module Locallingo
12
12
  # it was edited by a human — surface it so the operator can protect it with
13
13
  # `accept-edits` before the next translate run overwrites it.
14
14
  class ManualEdits
15
- def initialize(cli_name: "lingo")
15
+ def initialize(cli_name: "lingo", drift: Drift.new)
16
16
  @cli_name = cli_name
17
+ @drift = drift
17
18
  end
18
19
 
19
20
  # +target+ is the flat target hash; +locale_state+ its loaded state.
20
21
  def call(target:, locale_state:, locale:)
21
22
  target.filter_map do |key, value|
22
- entry = locale_state[key]
23
- next unless entry.is_a?(Hash)
24
- next if entry["manual"]
25
-
26
- stored = entry["target_hash"]
27
- next unless stored && stored != StateStore.hash(value)
23
+ next unless @drift.hand_edited?(target: value, entry: locale_state[key])
28
24
 
29
25
  {
30
26
  type: :manual_edit,
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../state_store"
3
+ require_relative "../drift"
4
4
 
5
5
  module Locallingo
6
6
  module Validators
7
7
  # Reports target keys whose recorded source hash no longer matches the
8
8
  # current source value — i.e. the English text changed after translation.
9
9
  class Outdated
10
- def initialize(cli_name: "lingo")
10
+ def initialize(cli_name: "lingo", drift: Drift.new)
11
11
  @cli_name = cli_name
12
+ @drift = drift
12
13
  end
13
14
 
14
15
  # +source+ is the flat source hash; +locale_state+ is the target locale's
@@ -27,8 +28,7 @@ module Locallingo
27
28
  # Keys whose stored source_hash differs from the current source hash.
28
29
  def outdated_keys(source, locale_state)
29
30
  source.filter_map do |key, value|
30
- stored = locale_state.dig(key, "source_hash")
31
- key if stored && stored != StateStore.hash(value)
31
+ key if @drift.outdated?(source: value, entry: locale_state[key])
32
32
  end
33
33
  end
34
34
 
@@ -37,7 +37,7 @@ module Locallingo
37
37
  # Manual keys must not be pushed toward machine retranslation — the value
38
38
  # is hand-curated, so the human updates it and re-accepts.
39
39
  def suggestion_for(key, locale, locale_state)
40
- if locale_state.dig(key, "manual")
40
+ if @drift.manual?(locale_state[key])
41
41
  "Source changed for a manually-curated key. Update the #{locale} value by hand, " \
42
42
  "then run: #{@cli_name} accept-edits --locale #{locale} --key #{key}"
43
43
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Locallingo
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/locallingo.rb CHANGED
@@ -6,8 +6,12 @@ require_relative "locallingo/configuration"
6
6
  require_relative "locallingo/json_extraction"
7
7
  require_relative "locallingo/key_flattener"
8
8
  require_relative "locallingo/state_store"
9
+ require_relative "locallingo/drift"
9
10
  require_relative "locallingo/providers/ruby_llm"
10
11
  require_relative "locallingo/batch_translator"
12
+ require_relative "locallingo/prompt"
13
+ require_relative "locallingo/translator"
14
+ require_relative "locallingo/record_translator"
11
15
  require_relative "locallingo/validators/missing"
12
16
  require_relative "locallingo/validators/outdated"
13
17
  require_relative "locallingo/validators/duplicate_values"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: locallingo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -69,21 +69,27 @@ files:
69
69
  - config/locallingo.default.yml
70
70
  - exe/lingo
71
71
  - lib/locallingo.rb
72
+ - lib/locallingo/active_record.rb
72
73
  - lib/locallingo/batch_translator.rb
73
74
  - lib/locallingo/cli.rb
74
75
  - lib/locallingo/configuration.rb
76
+ - lib/locallingo/content_leaves.rb
77
+ - lib/locallingo/drift.rb
75
78
  - lib/locallingo/json_extraction.rb
76
79
  - lib/locallingo/key_flattener.rb
77
80
  - lib/locallingo/manager.rb
81
+ - lib/locallingo/prompt.rb
78
82
  - lib/locallingo/providers/ruby_llm.rb
79
83
  - lib/locallingo/quality/british_spellings.rb
80
84
  - lib/locallingo/quality/static_rules.rb
81
85
  - lib/locallingo/quality/terminology.rb
82
86
  - lib/locallingo/quality_checker.rb
87
+ - lib/locallingo/record_translator.rb
83
88
  - lib/locallingo/reporter.rb
84
89
  - lib/locallingo/rubocop.rb
85
90
  - lib/locallingo/settings.rb
86
91
  - lib/locallingo/state_store.rb
92
+ - lib/locallingo/translator.rb
87
93
  - lib/locallingo/validators/duplicate_values.rb
88
94
  - lib/locallingo/validators/manual_edits.rb
89
95
  - lib/locallingo/validators/missing.rb