locallingo 0.5.0 → 0.6.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.
@@ -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
@@ -164,11 +163,12 @@ module Locallingo
164
163
  combined
165
164
  end
166
165
 
167
- # Run the configured after_translate hook commands (from the app root).
166
+ # Run the configured after_translate hook commands (from the app root, in
167
+ # the child process only: the process-wide cwd is never changed).
168
168
  def run_after_translate_hooks
169
169
  config.after_translate.each do |command|
170
170
  log("Running: #{command}")
171
- Dir.chdir(config.root_path) { system(command) }
171
+ system(command, chdir: config.root_path)
172
172
  end
173
173
  end
174
174
 
@@ -179,7 +179,8 @@ module Locallingo
179
179
 
180
180
  def batch_translator
181
181
  @batch_translator ||= BatchTranslator.new(
182
- provider: @provider, model: config.translate_model, batch_size: config.batch_size, log: method(:log)
182
+ provider: @provider, model: config.translate_model, batch_size: config.batch_size,
183
+ max_batch_chars: config.max_batch_chars, pause: config.pause, log: method(:log)
183
184
  )
184
185
  end
185
186
 
@@ -208,10 +209,7 @@ module Locallingo
208
209
  return target.keys.select { |key| source.key?(key) } if all
209
210
 
210
211
  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])
212
+ source.key?(key) && @drift.hand_edited?(target: target[key], entry: locale_state[key])
215
213
  end
216
214
  end
217
215
 
@@ -261,47 +259,10 @@ module Locallingo
261
259
  failed
262
260
  end
263
261
 
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
262
+ def translation_prompt(locale) = Prompt.translation(config:, from: config.source_locale, to: locale)
302
263
 
303
264
  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
265
+ manual_keys = locale_state.select { |_key, entry| @drift.manual?(entry) }.keys.to_set
305
266
  return source.keys - manual_keys.to_a if force
306
267
  return force_keys & source.keys if force_keys.any?
307
268
 
@@ -314,7 +275,7 @@ module Locallingo
314
275
  # Drops manual keys from the named force_keys (with a loud warning) unless
315
276
  # the caller opted in to overwriting them.
316
277
  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"] }
278
+ manual = (force_keys & source.keys).select { |key| @drift.manual?(locale_state[key]) }
318
279
  return force_keys if manual.empty?
319
280
 
320
281
  if include_manual
@@ -335,15 +296,11 @@ module Locallingo
335
296
  translations.each_key do |key|
336
297
  next unless source[key]
337
298
 
338
- existing = locale_state[key]
339
- entry = {
340
- "source_hash" => @state.hash(source[key]),
341
- "target_hash" => @state.hash(translations[key])
342
- }
343
299
  # A force-keyed retranslation overwrites a manual value only with
344
300
  # 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
301
+ locale_state[key] = @drift.record(
302
+ source: source[key], target: translations[key], manual: @drift.manual?(locale_state[key])
303
+ )
347
304
  end
348
305
  end
349
306
 
@@ -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,12 @@ 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
+ # Every chat runs on a `RubyLLM::Context`: the one given
23
+ # (`RubyLLM.context { |c| c.anthropic_api_key = … }`), else a fresh one built
24
+ # for that chat from the resolved key. The global `RubyLLM.config` is never
25
+ # written, so translators with different keys (one per tenant) can run
26
+ # concurrently.
21
27
  class RubyLLM
22
28
  # Maps a RubyLLM provider symbol to the ENV var whose presence indicates
23
29
  # credentials are available, so we can fail fast with a clear message
@@ -30,10 +36,11 @@ module Locallingo
30
36
  openrouter: "OPENROUTER_API_KEY"
31
37
  }.freeze
32
38
 
33
- attr_reader :provider
39
+ attr_reader :provider, :context
34
40
 
35
- def initialize(provider:)
41
+ def initialize(provider:, context: nil)
36
42
  @provider = provider.to_sym
43
+ @context = context
37
44
  end
38
45
 
39
46
  # True when a key for the configured provider is found in any source
@@ -50,6 +57,12 @@ module Locallingo
50
57
  def ensure_credentials!
51
58
  return if credentials?
52
59
 
60
+ if context
61
+ raise MissingCredentialsError,
62
+ "No credentials for provider #{provider.inspect}: the given RubyLLM context " \
63
+ "has no #{key_setting}."
64
+ end
65
+
53
66
  raise MissingCredentialsError,
54
67
  "No credentials for provider #{provider.inspect}. " \
55
68
  "Set #{CREDENTIAL_ENV[provider]} in ENV, call " \
@@ -61,46 +74,50 @@ module Locallingo
61
74
  # the model and return the parsed JSON object as a Hash.
62
75
  def chat(model:, instructions:, payload:)
63
76
  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
77
 
78
+ conversation = new_chat(model).with_instructions(instructions, cache_until_here: true)
72
79
  response = conversation.ask(JSON.pretty_generate(payload))
73
80
  JsonExtraction.extract_object(response.content)
74
81
  end
75
82
 
76
83
  private
77
84
 
78
- # RubyLLM does not read provider API keys from ENV on its own, so a
79
- # standalone CLI run (no Rails initializer to call RubyLLM.configure)
80
- # would raise "Missing configuration for <provider>". Push the resolved
81
- # key into RubyLLM's config: an explicit `Locallingo.configure` key wins
82
- # (re-resolved every chat so callables stay live), then a key the host
83
- # app already set, then the ENV fallback.
84
- def configure_credentials!
85
- return unless CREDENTIAL_ENV.key?(provider)
86
-
87
- config = ::RubyLLM.config
88
- setting = key_setting
89
- return unless config.respond_to?(setting) && config.respond_to?("#{setting}=")
90
-
91
- key = settings_api_key
92
- return if key.nil? && !presence(config.public_send(setting)).nil?
85
+ def new_chat(model)
86
+ (context || chat_context).chat(model:, provider:, assume_model_exists: true)
87
+ end
93
88
 
94
- key ||= env_api_key
95
- config.public_send("#{setting}=", key) unless key.nil?
89
+ # A context of its own for one chat. `RubyLLM.context` copies the global
90
+ # config, so a key the host app set via RubyLLM.configure carries over;
91
+ # an explicit `Locallingo.configure` key wins over it (re-resolved every
92
+ # chat so callables stay live), and ENV fills in when neither is set
93
+ # (RubyLLM does not read provider keys from ENV on its own, so a
94
+ # standalone CLI run would otherwise raise "Missing configuration").
95
+ def chat_context
96
+ ::RubyLLM.context do |config|
97
+ setting = key_setting
98
+ next unless CREDENTIAL_ENV.key?(provider) && config.respond_to?("#{setting}=")
99
+
100
+ key = settings_api_key
101
+ key ||= env_api_key if presence(config.public_send(setting)).nil?
102
+ config.public_send("#{setting}=", key) unless key.nil?
103
+ end
96
104
  end
97
105
 
98
106
  # Key resolution across sources, in precedence order. Used by
99
107
  # #credentials? as a fail-fast check before any network call.
100
108
  def resolved_api_key
109
+ return context_api_key if context
110
+
101
111
  settings_api_key || host_configured_api_key || env_api_key
102
112
  end
103
113
 
114
+ # With a context, its own config is the only source: the chat never
115
+ # reads the settings, the global config or ENV.
116
+ def context_api_key
117
+ config = context.config
118
+ config.respond_to?(key_setting) ? presence(config.public_send(key_setting)) : nil
119
+ end
120
+
104
121
  def settings_api_key
105
122
  Locallingo.settings.api_key_for(provider)
106
123
  end
@@ -11,6 +11,7 @@ module Locallingo
11
11
  "Can not" => "Cannot"
12
12
  }.freeze
13
13
 
14
+ # Every level frozen, so any Ractor can read the rules.
14
15
  RULES = {
15
16
  terminology: {
16
17
  /\bcan not\b/i => "Use 'cannot' (one word)",
@@ -18,11 +19,11 @@ module Locallingo
18
19
  "Use 'log in' (verb) not 'login' here",
19
20
  /\bclick\s+here\b/i => "Avoid 'click here' - use descriptive link text",
20
21
  /\bplease\b.*\bplease\b/i => "Multiple 'please' in same text - remove redundancy"
21
- },
22
+ }.freeze,
22
23
  placeholders: {
23
24
  /%\{[^}]*\s[^}]*\}/ => "Placeholder contains spaces - may cause issues",
24
25
  /%\{[A-Z]/ => "Placeholder starts with uppercase - convention is lowercase"
25
- },
26
+ }.freeze,
26
27
  clarity: {
27
28
  /\betc\.?\b/i => "Avoid 'etc.' - be specific or use 'and more'",
28
29
  /\bstuff\b/i => "Vague word 'stuff' - be more specific",
@@ -30,19 +31,19 @@ module Locallingo
30
31
  /\basap\b/i => "Avoid abbreviation 'ASAP' - use 'as soon as possible'",
31
32
  /\bfyi\b/i => "Avoid abbreviation 'FYI' - rephrase",
32
33
  /\s{2,}/ => "Multiple consecutive spaces"
33
- },
34
+ }.freeze,
34
35
  business: {
35
36
  /\bsorry\b/i => "Consider 'We apologize' for formal business tone",
36
37
  /\boops\b/i => "Informal - use professional error messaging",
37
38
  /\buh\s*oh\b/i => "Informal - use professional error messaging",
38
39
  /\bawesome\b/i => "Consider more professional alternatives",
39
40
  /\bcool\b(?!\s*down)/i => "Informal - consider 'great' or 'excellent'"
40
- },
41
+ }.freeze,
41
42
  accessibility: {
42
43
  /\bsee\s+below\b/i => "Screen reader unfriendly - describe the content",
43
44
  /\babove\b/i => "Positional reference - may not work for all users",
44
45
  /\bred\b.*\berror\b|\berror\b.*\bred\b/i => "Don't rely on color alone for meaning"
45
- }
46
+ }.freeze
46
47
  }.freeze
47
48
 
48
49
  module_function
@@ -36,7 +36,7 @@ module Locallingo
36
36
  "cdd" => nil
37
37
  ).freeze
38
38
 
39
- BUILTINS = { "business" => BUSINESS, "banking" => BANKING, "none" => {} }.freeze
39
+ BUILTINS = { "business" => BUSINESS, "banking" => BANKING, "none" => {}.freeze }.freeze
40
40
 
41
41
  attr_reader :terms
42
42
 
@@ -0,0 +1,94 @@
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
+ # A capture-free lambda, made shareable where the engine has Ractors, so
22
+ # the constant is readable from any Ractor.
23
+ DEFAULT_WRITE = ->(record, attributes) { record.update_columns(attributes) }
24
+ .then { |write| defined?(::Ractor) ? ::Ractor.make_shareable(write) : write }
25
+
26
+ # +write+ is called as `write.call(record, attributes)` inside the lock,
27
+ # e.g. to bump a lock version or run validations (`record.update!(attributes)`).
28
+ def initialize(translator:, write: DEFAULT_WRITE)
29
+ @translator = translator
30
+ @write = write
31
+ end
32
+
33
+ def call(records, to:, style: :ui, instructions: nil, force: false, include_manual: false)
34
+ @locale = to.to_s
35
+ @force = force
36
+ @include_manual = include_manual
37
+ @result = { translated: 0, failed: {}, skipped: [] }
38
+
39
+ due, empty = records.select { |record| due?(record) }.partition { |record| record.translation_segments.any? }
40
+ @result[:skipped].concat(empty.map(&:id))
41
+ due.group_by(&:translation_source_locale).each do |from, group|
42
+ translate_group(group, from:, style:, instructions:)
43
+ end
44
+ @result
45
+ end
46
+
47
+ private
48
+
49
+ def due?(record) = record.translation_due?(@locale, force: @force, include_manual: @include_manual)
50
+
51
+ def translate_group(records, from:, style:, instructions:)
52
+ digests = records.to_h { |record| [record, record.translation_source_digest] }
53
+ owners = {}
54
+ segments = records.each_with_object({}) do |record, all|
55
+ record.translation_segments.each do |path, text|
56
+ key = "#{record.id}.#{path}"
57
+ raise Error, "Segment key #{key} is ambiguous across records (an id containing \".\"?)" if owners.key?(key)
58
+
59
+ owners[key] = [record, path]
60
+ all[key] = text
61
+ end
62
+ end
63
+
64
+ result = @translator.translate(segments, from:, to: @locale, style:, instructions:)
65
+ records.each do |record|
66
+ translations = own(result.translations, owners, record).to_h
67
+ failed = own(result.failed.to_h { |key| [key, nil] }, owners, record).map(&:first)
68
+ @result[:failed][record.id] = failed if failed.any?
69
+ # All or nothing per record: one state entry covers the whole locale,
70
+ # so a partial write would read as current with values missing.
71
+ write(record, translations, digests[record]) if failed.empty? && translations.any?
72
+ end
73
+ end
74
+
75
+ # The +record+'s pairs from +pairs+, keyed by its own segment path.
76
+ def own(pairs, owners, record)
77
+ pairs.filter_map do |key, value|
78
+ owner, path = owners[key]
79
+ [path, value] if owner.equal?(record)
80
+ end
81
+ end
82
+
83
+ def write(record, translations, digest)
84
+ record.with_lock do
85
+ next @result[:skipped] << record.id unless record.translation_source_digest == digest && due?(record)
86
+
87
+ manual = record.translation_manual?(@locale)
88
+ target = record.build_translation_target(translations)
89
+ @write.call(record, record.translation_attributes(@locale, target, manual:))
90
+ @result[:translated] += 1
91
+ end
92
+ end
93
+ end
94
+ end
@@ -16,6 +16,6 @@ require_relative "../rubocop/cop/locallingo/strftime_in_view"
16
16
  module Locallingo
17
17
  # Absolute path to the cop defaults, for `inherit_gem: locallingo: <path>`.
18
18
  module RuboCop
19
- CONFIG_DEFAULT = File.expand_path("../../config/default.yml", __dir__)
19
+ CONFIG_DEFAULT = File.expand_path("../../config/default.yml", __dir__).freeze
20
20
  end
21
21
  end
@@ -12,10 +12,21 @@ module Locallingo
12
12
  #
13
13
  # Callables are resolved lazily on every use (never memoized), so keys can
14
14
  # come from sources that aren't ready at configure time or that rotate.
15
+ #
16
+ # A String key is stored as a frozen plain String copy, so settings frozen by
17
+ # `Locallingo.configure` are Ractor-shareable; a callable is kept as is.
15
18
  class Settings
16
19
  PROVIDERS = %i[openai anthropic gemini deepseek openrouter].freeze
17
20
 
18
- attr_accessor(*PROVIDERS.map { |name| :"#{name}_api_key" })
21
+ attr_reader(*PROVIDERS.map { |name| :"#{name}_api_key" })
22
+
23
+ PROVIDERS.each do |name|
24
+ define_method(:"#{name}_api_key=") do |value|
25
+ # A plain String copy: a subclass or its ivars could carry mutable state.
26
+ value = String.new(value).freeze if value.is_a?(String)
27
+ instance_variable_set(:"@#{name}_api_key", value)
28
+ end
29
+ end
19
30
 
20
31
  # The usable key for +provider+: callables are called, results stripped,
21
32
  # and blank or unknown-provider values come back as nil.
@@ -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