locallingo 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e31de70770225e0fa02a19b214791252d80b79b46f69ff02fe4a4966079fb3bf
4
- data.tar.gz: 1b32c3ef52453c291676585fa0ca6a1da301879d292b98de13c017c6d03195e6
3
+ metadata.gz: 4296369c42c6c23ce6a15be0cc51adf0976fd6c07fd6b4514cd1a945c7ec07f9
4
+ data.tar.gz: b842069155d70be0a3291f67637deb719096b3654825768265cc8007e9ae27c5
5
5
  SHA512:
6
- metadata.gz: c6293ed9127107f94800a144653e593aa2482aae0d5352dde2a7a02cd9043afedbcd441e112e3e3565e7842b981a9935623c70e94603a26fb0555e31b65397e9
7
- data.tar.gz: e20e64e38717897b90ab7607dfe032b52ecd3d9b12af37ec8615ca8811fe2d4fef6d3eefb6f00196e0b1d9f4eded368a0118f3dcbbddc2704d04f6ecb50106b3
6
+ metadata.gz: 9f586a4ea9bd383bf58370219d99535f73f83994a2181ca7974302787a727df645f54c5e20c1465dc9f9f3e1ac586772b3bd97069b52f68c4bc0f03b99cf1422
7
+ data.tar.gz: bc075cce32d4602a4c0f8672340eca7cc7edee7afd779b95fb2822d1454794d3f804816c9a50d7f095c19aa2042dc916b203556813ddaff71d918b6e87f08bb7
data/CHANGELOG.md CHANGED
@@ -6,12 +6,60 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+ - `lingo quality --prefix yoga.` checks only the keys under a prefix, in both
11
+ the static checks and the AI pass. A prefix that matches nothing exits `1`. (#10, #29)
12
+ - `lingo quality --key a.b` (repeatable) checks exactly the named keys, and the
13
+ AI pass reviews every named key without sampling. An unknown key exits `1`;
14
+ `--key` and `--prefix` cannot be combined. (#10, #29)
15
+ - `quality.sample_size` (default `100`) sets how many keys `quality --ai`
16
+ samples. (#10, #29)
17
+ - `lingo translate --include-manual` lets `--force-key` overwrite keys flagged
18
+ `manual`; each overwritten key is named on stderr and its `manual` flag
19
+ survives. (#11, #30)
20
+
9
21
  ### Changed
10
- - Allow `ruby_llm` 2.x (dependency constraint widened from `< 2` to `< 3`). The
11
- gem only uses RubyLLM's stable chat API and guarded config accessors, so a
12
- host app can take the RubyLLM 2.0 bump without waiting on Locallingo.
22
+ - `lingo translate --force-key` skips keys flagged `manual` (hand-edited, via
23
+ `accept-edits`) and names each skipped key on stderr, the same protection
24
+ `--force` already gave. If every named key is manual, nothing is translated.
25
+ Pass `--include-manual` to overwrite them as before. (#11, #30)
26
+ - The translation prompt asks for natural, idiomatic text in the target
27
+ language ("into natural, idiomatic German") instead of a word-for-word
28
+ translation: the meaning, phrasing and UI conventions a native speaker would
29
+ use, with idioms replaced by their local equivalent. Only newly translated or
30
+ outdated keys are affected; run `lingo translate --force` to redo all
31
+ non-manual keys (hand-edited `manual` keys stay untouched — redo those one at
32
+ a time with `--force-key <key> --include-manual` and compare).
33
+ The source language is now named too (`en` → "English").
34
+ - The translation prompt no longer hardcodes "Use formal business language".
35
+ Register (formal "Sie"/"Lei" vs. informal "du"/"tu") belongs in each
36
+ locale's `language_guides` entry; add it there to keep a formal tone.
37
+ - `lingo translate` exits `1` when any key fails to translate, and prints
38
+ `⚠️ Translation finished: N keys failed` with the failed keys instead of
39
+ `✅ Translation complete!`. Translated keys are still written and the
40
+ `after_translate` hooks still run; `--dry-run` reports failures but exits `0`.
41
+ Appending `|| true` restores the old always-`0` behaviour, but also hides
42
+ every other failure (missing credentials, a broken config). (#25)
43
+ - `Manager#translate!` returns `{ locale => failed_keys }`. (#25)
44
+ - Require `ruby_llm` >= 2.1 (constraint is now `>= 2.1, < 3`; host apps pinned
45
+ to RubyLLM 1.x or 2.0 must bump). The translation and quality-review system
46
+ prompts are marked as prompt cache boundaries; the translation prompt is
47
+ reused by every batch of a locale, so providers with prompt caching
48
+ (Anthropic, OpenAI-compatible) bill that repeated prefix at the cached rate. Anthropic only caches prompts
49
+ above its minimum length (~1024 tokens); shorter prompts are sent uncached.
13
50
 
14
51
  ### Fixed
52
+ - `lingo quality --ai` includes the locale's `language_guides` entry in the
53
+ review prompt, as `translate` does, so the AI pass can flag violations of the
54
+ guide (e.g. du/Sie register). (#9, #29)
55
+ - One invalid value in a model reply (e.g. typographic quotes returned as
56
+ unescaped `"`) no longer fails every key in its batch. A reply that cannot be
57
+ parsed is never resent as-is: the batch is split in half, down to single keys,
58
+ so only the bad key fails. Transport errors still retry with backoff. (#25)
59
+ - When a ```json fence holds invalid JSON, the parse error now names the line
60
+ and column inside the fence instead of the fence at line 1. (#25)
61
+ - The translation prompt asks the model to escape `"` inside values as `\"`
62
+ and keep typographic quotes (“ ” „ ‚ « ») as they appear in the source. (#25)
15
63
  - State files are written with a trailing final newline, so the output is
16
64
  POSIX-conformant and files that already end with a newline are no longer
17
65
  rewritten with newline-only diffs. One-time effect after upgrading: each
@@ -21,6 +69,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
21
69
  through `translate` or `accept-edits`) get a baseline and the `manual_edits`
22
70
  validator can watch them. An existing `target_hash` is still never
23
71
  recomputed — that would silently absorb hand-edit drift.
72
+ - The `duplicate_values` validator reports findings under the configured
73
+ `source_locale` instead of a hard-coded `en`. (#15)
24
74
 
25
75
  ### Fixed (0.4.0)
26
76
  - `lingo sync` no longer destroys hand-edit protection: it now only refreshes
data/README.md CHANGED
@@ -100,9 +100,11 @@ With no `packages:`, the whole app uses `defaults` — the common case.
100
100
  lingo status # translation status per locale
101
101
  lingo translate --locale de # translate missing/changed keys
102
102
  lingo translate --force # re-translate everything
103
+ lingo translate --force-key a.b.c # re-translate one key (skips manual; add --include-manual)
103
104
  lingo validate --strict # CI gate (exit 1 on strict-tier issues)
104
105
  lingo validate --strict-all # stricter CI gate
105
106
  lingo quality --ai # quality linting (+ optional AI pass)
107
+ lingo quality --ai --prefix yoga. # lint one namespace
106
108
  lingo fix-quality --locale en # auto-fix fixable issues
107
109
  lingo accept-edits --locale de # protect hand-edited translations (drifted keys only)
108
110
  lingo accept-edits --key a.b.c # protect a specific key
@@ -29,6 +29,9 @@ defaults:
29
29
 
30
30
  quality:
31
31
  model: gpt-4o-mini
32
+ # How many randomly sampled keys `quality --ai` sends for review per run
33
+ # (`--key` reviews every named key instead).
34
+ sample_size: 100
32
35
  # Flag en-only British-vs-American spelling drift (organization -> organisation).
33
36
  british_spellings: false
34
37
  # Named built-in terminology list ("business" | "banking" | "none"), or a
@@ -41,7 +44,8 @@ defaults:
41
44
  context: "a business application"
42
45
  placeholder_style: "%<name>s, %<count>s"
43
46
  glossary: {}
44
- # Extra per-locale style guidance appended to the translation prompt. Value is
47
+ # Extra per-locale style guidance appended to the translation and AI quality
48
+ # review prompts. Value is
45
49
  # inline text or `file: path/to/guide.md`.
46
50
  language_guides: {}
47
51
 
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Locallingo
6
+ # Sends keys to the provider in batches and collects the translations.
7
+ #
8
+ # - Transport/API errors retry the same payload with backoff.
9
+ # - A reply that cannot be parsed is never resent as-is: one bad value (e.g.
10
+ # an unescaped quote) would fail it again. The batch is halved instead,
11
+ # down to single keys, so a bad value costs only its own key.
12
+ # - Keys the model omitted are re-batched for a few rounds; keys whose own
13
+ # one-key reply could not be parsed are not.
14
+ class BatchTranslator
15
+ MAX_RETRIES = 3
16
+ MAX_MISSING_RETRIES = 2
17
+ BASE_SLEEP_DURATION = 1.0
18
+
19
+ # `log` is called as `log.call(message, level: :info)`.
20
+ def initialize(provider:, model:, batch_size:, log:)
21
+ @provider = provider
22
+ @model = model
23
+ @batch_size = batch_size
24
+ @log = log
25
+ end
26
+
27
+ # Translates `keys` (looked up in `source`) and returns
28
+ # `[translations, failed_keys]`.
29
+ def call(source, keys, instructions:)
30
+ @source = source
31
+ @instructions = instructions
32
+ @unparseable = []
33
+
34
+ translated, missing = translate_keys(keys)
35
+ missing = retry_missing(translated, missing)
36
+
37
+ failed = missing + @unparseable
38
+ report_failed(failed)
39
+ [translated, failed]
40
+ end
41
+
42
+ private
43
+
44
+ def retry_missing(translated, missing)
45
+ missing -= @unparseable
46
+ round = 0
47
+ while missing.any? && round < MAX_MISSING_RETRIES
48
+ round += 1
49
+ log(" Retry round #{round}: #{missing.size} keys remaining...")
50
+ sleep(BASE_SLEEP_DURATION * (2**round))
51
+ retried, missing = translate_keys(missing)
52
+ translated.merge!(retried)
53
+ missing -= @unparseable
54
+ end
55
+ missing
56
+ end
57
+
58
+ def report_failed(failed)
59
+ return if failed.empty?
60
+
61
+ log(" WARNING: #{failed.size} keys failed after all retries:", level: :warn)
62
+ failed.each { |key| log(" - #{key}", level: :warn) }
63
+ end
64
+
65
+ def translate_keys(keys)
66
+ translations = {}
67
+ failed = []
68
+
69
+ keys.each_slice(@batch_size) do |batch|
70
+ result = translate_batch(batch.to_h { |key| [key, @source[key]] })
71
+ batch.each do |key|
72
+ if result.key?(key) && !result[key].to_s.empty?
73
+ translations[key] = result[key]
74
+ else
75
+ failed << key
76
+ end
77
+ end
78
+ sleep(BASE_SLEEP_DURATION)
79
+ end
80
+
81
+ [translations, failed]
82
+ end
83
+
84
+ def translate_batch(payload)
85
+ return {} if payload.empty?
86
+
87
+ request_batch(payload)
88
+ rescue JSON::ParserError => e
89
+ split_unparseable_batch(payload, e)
90
+ end
91
+
92
+ def split_unparseable_batch(payload, error)
93
+ if payload.size == 1
94
+ log(" Unparseable reply for #{payload.keys.first}: #{error.message}", level: :error)
95
+ @unparseable.concat(payload.keys)
96
+ return {}
97
+ end
98
+
99
+ log(" Unparseable reply for #{payload.size} keys, splitting the batch: #{error.message}", level: :warn)
100
+ payload.each_slice((payload.size / 2.0).ceil).with_object({}) do |half, result|
101
+ result.merge!(translate_batch(half.to_h))
102
+ end
103
+ end
104
+
105
+ def request_batch(payload)
106
+ retries = 0
107
+ begin
108
+ result = @provider.chat(model: @model, instructions: @instructions, payload:)
109
+ log(" Batch translated: #{result.keys.size}/#{payload.keys.size} keys")
110
+ result
111
+ rescue JSON::ParserError
112
+ raise
113
+ rescue StandardError => e
114
+ retries += 1
115
+ if retries < MAX_RETRIES
116
+ sleep_duration = BASE_SLEEP_DURATION * (2**retries)
117
+ log(" Batch failed (attempt #{retries}), retrying in #{sleep_duration}s: #{e.message}", level: :warn)
118
+ sleep(sleep_duration)
119
+ retry
120
+ end
121
+ log(" Translation batch failed after #{MAX_RETRIES} retries: #{e.message}", level: :error)
122
+ {}
123
+ end
124
+ end
125
+
126
+ def log(message, level: :info) = @log.call(message, level:)
127
+ end
128
+ end
@@ -99,17 +99,24 @@ module Locallingo
99
99
  @options
100
100
  end
101
101
 
102
+ def add_force_options(opts)
103
+ opts.on("-f", "--force", "Force re-translation of all keys") { @options[:force] = true }
104
+ opts.on("--force-key KEY", "Force re-translation of a specific key") do |v|
105
+ (@options[:force_keys] ||= []) << v
106
+ end
107
+ opts.on("--include-manual", "translate: let --force-key overwrite manual (hand-edited) keys") do
108
+ @options[:include_manual] = true
109
+ end
110
+ end
111
+
102
112
  def build_parser
103
113
  OptionParser.new do |opts|
104
114
  opts.banner = "Usage: #{CLI_NAME} <command> [options]\n\nCommands:\n " \
105
115
  "#{COMMANDS.keys.join(", ")}\n\nOptions:"
106
116
 
107
117
  opts.on("-l", "--locale LOCALE", "Process specific locale") { |v| @options[:locale] = v }
108
- opts.on("-f", "--force", "Force re-translation of all keys") { @options[:force] = true }
109
- opts.on("--force-key KEY", "Force re-translation of a specific key") do |v|
110
- (@options[:force_keys] ||= []) << v
111
- end
112
- opts.on("--key KEY", "accept-edits: accept a specific key (repeatable)") do |v|
118
+ add_force_options(opts)
119
+ opts.on("--key KEY", "accept-edits, quality: act on a specific key (repeatable)") do |v|
113
120
  (@options[:keys] ||= []) << v
114
121
  end
115
122
  opts.on("--all", "accept-edits: mark every translated key as manual") { @options[:all] = true }
@@ -120,7 +127,7 @@ module Locallingo
120
127
  @options[:strict] = true
121
128
  @options[:strict_all] = true
122
129
  end
123
- opts.on("--ai", "Use AI for quality suggestions") { @options[:use_ai] = true }
130
+ quality_options(opts)
124
131
  opts.on("--json", "Output in JSON format") { @options[:format] = :json }
125
132
  opts.on("--package PATH", "Scope to a package from .locallingo.yml") { |v| @options[:package] = v }
126
133
  opts.on("-h", "--help", "Show this help") do
@@ -130,6 +137,13 @@ module Locallingo
130
137
  end
131
138
  end
132
139
 
140
+ def quality_options(opts)
141
+ opts.on("--ai", "Use AI for quality suggestions") { @options[:use_ai] = true }
142
+ opts.on("--prefix PREFIX", "quality: check only keys starting with PREFIX (e.g. yoga.)") do |v|
143
+ @options[:prefix] = v
144
+ end
145
+ end
146
+
133
147
  def dispatch(command, config, options)
134
148
  reporter = Reporter.new(config:, format: options[:format], cli_name: CLI_NAME)
135
149
 
@@ -155,13 +169,29 @@ module Locallingo
155
169
  def cmd_translate(config, options)
156
170
  mgr = manager(config, options)
157
171
  puts "🔄 Translating..." if options[:verbose]
158
- mgr.translate!(locale: options[:locale], force: options[:force], force_keys: options[:force_keys] || [])
172
+ failed = mgr.translate!(locale: options[:locale], force: options[:force],
173
+ force_keys: options[:force_keys] || [],
174
+ include_manual: options.fetch(:include_manual, false))
159
175
  unless options[:dry_run]
160
176
  puts "📝 Running post-translate hooks..."
161
177
  mgr.run_after_translate_hooks
162
178
  end
163
- puts "✅ Translation complete!"
164
- puts "(dry run - no changes made)" if options[:dry_run]
179
+ report_translated(failed, dry_run: options[:dry_run])
180
+ end
181
+
182
+ # Hooks have already run for the keys that were written; a failed key still
183
+ # fails the run (outside --dry-run) so CI does not ship missing translations.
184
+ def report_translated(failed, dry_run:)
185
+ count = failed.values.sum(&:size)
186
+ if count.zero?
187
+ puts "✅ Translation complete!"
188
+ else
189
+ puts "⚠️ Translation finished: #{count} #{count == 1 ? "key" : "keys"} failed " \
190
+ "(re-run with -v for details)"
191
+ failed.each { |locale, keys| keys.each { |key| puts " - #{locale}: #{key}" } }
192
+ end
193
+ puts "(dry run - no changes made)" if dry_run
194
+ exit 1 if count.positive? && !dry_run
165
195
  end
166
196
 
167
197
  def cmd_validate(config, options, reporter)
@@ -171,7 +201,11 @@ module Locallingo
171
201
 
172
202
  def cmd_quality(config, options, reporter)
173
203
  locale = options[:locale] || config.source_locale
174
- suggestions = quality_checker(config, options).check(locale:, use_ai: options[:use_ai])
204
+ raise Error, "--key and --prefix cannot be combined" if options[:keys] && options[:prefix]
205
+
206
+ suggestions = quality_checker(config, options).check(
207
+ locale:, use_ai: options[:use_ai], prefix: options[:prefix], keys: options[:keys]
208
+ )
175
209
  reporter.quality(suggestions, locale:)
176
210
  end
177
211
 
@@ -55,6 +55,7 @@ module Locallingo
55
55
  def translate_model = dig("translate", "model")
56
56
  def batch_size = dig("translate", "batch_size")
57
57
  def quality_model = dig("quality", "model")
58
+ def quality_sample_size = dig("quality", "sample_size")
58
59
  def british_spellings? = !!dig("quality", "british_spellings")
59
60
  def terminology_setting = dig("quality", "terminology")
60
61
 
@@ -99,7 +100,7 @@ module Locallingo
99
100
  def after_translate = Array(data.fetch("after_translate", []))
100
101
 
101
102
  BUILTIN_LANGUAGE_NAMES = {
102
- "de" => "German", "sv" => "Swedish", "fr" => "French",
103
+ "en" => "English", "de" => "German", "sv" => "Swedish", "fr" => "French",
103
104
  "af" => "Afrikaans", "es" => "Spanish", "it" => "Italian",
104
105
  "nl" => "Dutch", "pt" => "Portuguese", "da" => "Danish",
105
106
  "nb" => "Norwegian", "no" => "Norwegian", "fi" => "Finnish"
@@ -24,10 +24,11 @@ module Locallingo
24
24
  def extract_object(content)
25
25
  text = content.to_s.strip
26
26
 
27
+ fenced = fenced_block(text)
27
28
  parsed = try_parse(text) ||
28
- try_parse(fenced_block(text)) ||
29
+ try_parse(fenced) ||
29
30
  first_balanced_object(text) ||
30
- JSON.parse(text) # final attempt; raises with a useful message
31
+ raise_parse_error(text, fenced)
31
32
 
32
33
  # Both callers treat the result as a key->value Hash, so reject a
33
34
  # top-level array (or any non-object) here with a clear contract error
@@ -46,6 +47,17 @@ module Locallingo
46
47
  nil
47
48
  end
48
49
 
50
+ # Raise the most useful parse error. A complete fence that fails to parse
51
+ # holds the real fault (e.g. an unescaped quote inside a value); parsing the
52
+ # raw text would only report the fence itself at line 1.
53
+ def raise_parse_error(text, fenced)
54
+ JSON.parse(fenced || text)
55
+ rescue JSON::ParserError => e
56
+ raise e unless fenced
57
+
58
+ raise JSON::ParserError, "#{e.message} (inside the fenced block)"
59
+ end
60
+
49
61
  def fenced_block(text)
50
62
  text[/```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```/m, 1]
51
63
  end
@@ -9,6 +9,7 @@ require_relative "configuration"
9
9
  require_relative "key_flattener"
10
10
  require_relative "state_store"
11
11
  require_relative "providers/ruby_llm"
12
+ require_relative "batch_translator"
12
13
  require_relative "validators/missing"
13
14
  require_relative "validators/outdated"
14
15
  require_relative "validators/duplicate_values"
@@ -25,9 +26,9 @@ module Locallingo
25
26
  # Everything app-specific (locales, provider/model, prompt context/glossary,
26
27
  # per-language guides, which validators run) comes from the Configuration.
27
28
  class Manager
28
- MAX_RETRIES = 3
29
- MAX_MISSING_RETRIES = 2
30
- BASE_SLEEP_DURATION = 1.0
29
+ MAX_RETRIES = BatchTranslator::MAX_RETRIES
30
+ MAX_MISSING_RETRIES = BatchTranslator::MAX_MISSING_RETRIES
31
+ BASE_SLEEP_DURATION = BatchTranslator::BASE_SLEEP_DURATION
31
32
 
32
33
  attr_reader :config, :dry_run, :verbose, :logger, :cli_name
33
34
 
@@ -69,7 +70,9 @@ module Locallingo
69
70
  violations = []
70
71
  source = load_source_translations
71
72
 
72
- violations.concat(Validators::DuplicateValues.new.call(source:)) if config.validator_enabled?(:duplicate_values)
73
+ if config.validator_enabled?(:duplicate_values)
74
+ violations.concat(Validators::DuplicateValues.new.call(source:, locale: config.source_locale))
75
+ end
73
76
 
74
77
  config.target_locales.each do |locale|
75
78
  target = load_locale_translations(locale)
@@ -87,14 +90,20 @@ module Locallingo
87
90
  violations
88
91
  end
89
92
 
90
- # Translate missing/changed keys for one or all target locales.
91
- def translate!(locale: nil, force: false, force_keys: [])
93
+ # Translate missing/changed keys for one or all target locales. Returns
94
+ # `{ locale => failed_keys }`; keys that failed are not written.
95
+ #
96
+ # Manual (hand-edited) keys are skipped by `force` and `force_keys`;
97
+ # `include_manual` lets the named `force_keys` overwrite them anyway.
98
+ def translate!(locale: nil, force: false, force_keys: [], include_manual: false)
92
99
  @provider.ensure_credentials!
93
100
 
94
101
  locales_to_process = locale ? [locale] : config.target_locales
95
102
  source = load_source_translations
96
103
 
97
- locales_to_process.each { |target_locale| translate_locale(source, target_locale, force:, force_keys:) }
104
+ locales_to_process.to_h do |target_locale|
105
+ [target_locale, translate_locale(source, target_locale, force:, force_keys:, include_manual:)]
106
+ end
98
107
  end
99
108
 
100
109
  # Mark hand-edited target values as intentional (source_hash + target_hash +
@@ -168,6 +177,12 @@ module Locallingo
168
177
  def missing_validator = @missing_validator ||= Validators::Missing.new(cli_name:)
169
178
  def outdated_validator = @outdated_validator ||= Validators::Outdated.new(cli_name:)
170
179
 
180
+ def batch_translator
181
+ @batch_translator ||= BatchTranslator.new(
182
+ provider: @provider, model: config.translate_model, batch_size: config.batch_size, log: method(:log)
183
+ )
184
+ end
185
+
171
186
  def sync_locale_state(source, locale)
172
187
  target = load_locale_translations(locale)
173
188
  locale_state = @state.load(locale)
@@ -210,21 +225,30 @@ module Locallingo
210
225
  raise Error, "accept-edits: key(s) not found in any target locale: #{missing.join(", ")}"
211
226
  end
212
227
 
213
- def translate_locale(source, target_locale, force:, force_keys:)
228
+ def translate_locale(source, target_locale, force:, force_keys:, include_manual:)
214
229
  log("Processing #{target_locale}...")
215
230
 
216
231
  target = load_locale_translations(target_locale)
217
232
  locale_state = @state.load(target_locale)
218
233
  exceptions = load_exceptions(target_locale)
219
234
 
235
+ if force_keys.any? && !force
236
+ force_keys = resolve_force_keys(source, force_keys, locale_state, include_manual:, locale: target_locale)
237
+ # Every named key was manual: translate nothing rather than falling
238
+ # back to the missing/outdated keys nobody asked for.
239
+ if force_keys.empty?
240
+ log(" No keys to translate for #{target_locale} (every --force-key is manual)")
241
+ return []
242
+ end
243
+ end
220
244
  keys = determine_keys_to_translate(source, target, locale_state, force:, force_keys:, exceptions:)
221
245
  if keys.empty?
222
246
  log(" No keys to translate for #{target_locale}")
223
- return
247
+ return []
224
248
  end
225
249
 
226
250
  log(" Translating #{keys.size} keys...")
227
- translated, failed = translate_with_missing_retries(source, keys, target_locale)
251
+ translated, failed = batch_translator.call(source, keys, instructions: translation_prompt(target_locale))
228
252
 
229
253
  successful = translated.except(*failed)
230
254
  unless dry_run
@@ -234,82 +258,29 @@ module Locallingo
234
258
  end
235
259
 
236
260
  log(" Completed #{target_locale}: #{successful.size} translated, #{failed.size} failed")
237
- end
238
-
239
- def translate_with_missing_retries(source, keys, target_locale)
240
- translated, failed = translate_keys(source, keys, target_locale)
241
-
242
- round = 0
243
- while failed.any? && round < MAX_MISSING_RETRIES
244
- round += 1
245
- log(" Retry round #{round}: #{failed.size} keys remaining...")
246
- sleep(BASE_SLEEP_DURATION * (2**round))
247
- retried, failed = translate_keys(source, failed, target_locale)
248
- translated.merge!(retried)
249
- end
250
-
251
- if failed.any?
252
- log(" WARNING: #{failed.size} keys failed after all retries:", level: :warn)
253
- failed.each { |key| log(" - #{key}", level: :warn) }
254
- end
255
-
256
- [translated, failed]
257
- end
258
-
259
- def translate_keys(source, keys, target_locale)
260
- translations = {}
261
- failed = []
262
-
263
- keys.each_slice(config.batch_size) do |batch|
264
- result = translate_batch(batch.to_h { |key| [key, source[key]] }, target_locale)
265
- batch.each do |key|
266
- if result.key?(key) && !result[key].to_s.empty?
267
- translations[key] = result[key]
268
- else
269
- failed << key
270
- end
271
- end
272
- sleep(BASE_SLEEP_DURATION)
273
- end
274
-
275
- [translations, failed]
276
- end
277
-
278
- def translate_batch(payload, target_locale)
279
- return {} if payload.empty?
280
-
281
- retries = 0
282
- begin
283
- result = @provider.chat(
284
- model: config.translate_model,
285
- instructions: translation_prompt(target_locale),
286
- payload:
287
- )
288
- log(" Batch translated: #{result.keys.size}/#{payload.keys.size} keys")
289
- result
290
- rescue StandardError => e
291
- retries += 1
292
- if retries < MAX_RETRIES
293
- sleep_duration = BASE_SLEEP_DURATION * (2**retries)
294
- log(" Batch failed (attempt #{retries}), retrying in #{sleep_duration}s: #{e.message}", level: :warn)
295
- sleep(sleep_duration)
296
- retry
297
- end
298
- log(" Translation batch failed after #{MAX_RETRIES} retries: #{e.message}", level: :error)
299
- {}
300
- end
261
+ failed
301
262
  end
302
263
 
303
264
  def translation_prompt(locale)
265
+ language = config.language_name(locale)
304
266
  <<~PROMPT
305
- Translate the following texts from #{config.language_name(config.source_locale)} to #{config.language_name(locale)}.
267
+ Translate the following texts from #{config.language_name(config.source_locale)} into natural, idiomatic #{language}.
306
268
  This is for #{config.context}.
307
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
+
308
278
  ## General Rules
309
279
  - Preserve placeholders like #{config.placeholder_style} exactly as they appear
310
280
  - Preserve HTML tags if present
311
- - Use formal business language
312
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
313
284
  #{glossary_section}#{language_guide_section(locale)}
314
285
  Return ONLY a raw JSON object mapping each input key to its translation,
315
286
  with no surrounding prose and no markdown code fences:
@@ -340,6 +311,26 @@ module Locallingo
340
311
  (missing + outdated).uniq
341
312
  end
342
313
 
314
+ # Drops manual keys from the named force_keys (with a loud warning) unless
315
+ # the caller opted in to overwriting them.
316
+ 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"] }
318
+ return force_keys if manual.empty?
319
+
320
+ if include_manual
321
+ warn_manual_keys(manual, locale, dry_run ? "would overwrite" : "overwriting")
322
+ force_keys
323
+ else
324
+ warn_manual_keys(manual, locale, dry_run ? "would skip" : "skipping", hint: true)
325
+ force_keys - manual
326
+ end
327
+ end
328
+
329
+ def warn_manual_keys(keys, locale, verb, hint: false)
330
+ keys.each { |key| warn("#{verb} manual (hand-edited) key #{key} for #{locale}") }
331
+ warn("Pass --include-manual to overwrite manual keys named via --force-key.") if hint
332
+ end
333
+
343
334
  def update_locale_state(source, locale_state, translations)
344
335
  translations.each_key do |key|
345
336
  next unless source[key]
@@ -349,8 +340,8 @@ module Locallingo
349
340
  "source_hash" => @state.hash(source[key]),
350
341
  "target_hash" => @state.hash(translations[key])
351
342
  }
352
- # A force-keyed retranslation may overwrite a manual value on explicit
353
- # request, but the protection flag itself must survive.
343
+ # A force-keyed retranslation overwrites a manual value only with
344
+ # include_manual, and even then the protection flag must survive.
354
345
  entry["manual"] = true if existing.is_a?(Hash) && existing["manual"]
355
346
  locale_state[key] = entry
356
347
  end
@@ -13,6 +13,11 @@ module Locallingo
13
13
  # truth) so independent batches don't share conversation history. The
14
14
  # response is parsed with the robust JsonExtraction extractor because not
15
15
  # every provider guarantees fenceless JSON.
16
+ #
17
+ # The system prompt is marked as a prompt cache boundary: the translation
18
+ # prompt is built once per locale and reused by every batch, so providers
19
+ # that support caching (Anthropic, OpenAI-compatible) bill the repeated
20
+ # prefix at the cached rate.
16
21
  class RubyLLM
17
22
  # Maps a RubyLLM provider symbol to the ENV var whose presence indicates
18
23
  # credentials are available, so we can fail fast with a clear message
@@ -62,7 +67,7 @@ module Locallingo
62
67
  model:,
63
68
  provider:,
64
69
  assume_model_exists: true
65
- ).with_instructions(instructions)
70
+ ).with_instructions(instructions, cache_until_here: true)
66
71
 
67
72
  response = conversation.ask(JSON.pretty_generate(payload))
68
73
  JsonExtraction.extract_object(response.content)
@@ -31,13 +31,15 @@ module Locallingo
31
31
  @terminology = Quality::Terminology.new(@config.terminology_setting, base_path: @config.base_path)
32
32
  end
33
33
 
34
- # Check all translations for a locale.
35
- def check(locale: nil, use_ai: false)
34
+ # Check the translations for a locale: all of them, those under +prefix+
35
+ # (e.g. "yoga."), or exactly the named +keys+. The AI pass reviews a sample
36
+ # of up to `quality.sample_size` keys, or every named key.
37
+ def check(locale: nil, use_ai: false, prefix: nil, keys: nil)
36
38
  locale ||= config.source_locale
37
- translations = load_locale_translations(locale)
39
+ translations = scoped_translations(locale, prefix:, keys:)
38
40
 
39
41
  suggestions = translations.flat_map { |key, text| check_text(key, text, locale) }
40
- suggestions.concat(ai_sample(translations, locale)) if use_ai
42
+ suggestions.concat(keys ? suggest_improvements(translations, locale) : ai_sample(translations, locale)) if use_ai
41
43
  suggestions
42
44
  end
43
45
 
@@ -116,8 +118,26 @@ module Locallingo
116
118
  }
117
119
  end
118
120
 
121
+ def scoped_translations(locale, prefix:, keys:)
122
+ translations = load_locale_translations(locale)
123
+ return slice_keys(translations, keys, locale) if keys
124
+ return translations unless prefix
125
+
126
+ scoped = translations.select { |key, _| key.start_with?(prefix) }
127
+ raise Error, "No keys match prefix #{prefix.inspect} in #{locale}" if scoped.empty?
128
+
129
+ scoped
130
+ end
131
+
132
+ def slice_keys(translations, keys, locale)
133
+ missing = keys - translations.keys
134
+ raise Error, "quality: key(s) not found in #{locale}: #{missing.join(", ")}" if missing.any?
135
+
136
+ translations.slice(*keys)
137
+ end
138
+
119
139
  def ai_sample(translations, locale)
120
- sample_size = [translations.size, 100].min
140
+ sample_size = [translations.size, config.quality_sample_size].min
121
141
  sample = translations.to_a.sample(sample_size).to_h
122
142
  suggest_improvements(sample, locale)
123
143
  end
@@ -153,7 +173,7 @@ module Locallingo
153
173
  <<~PROMPT
154
174
  Review these UI translations for #{config.context}.
155
175
  Suggest improvements for clarity, professionalism, and user-friendliness.
156
- #{glossary_section}
176
+ #{glossary_section}#{language_guide_section(locale)}
157
177
  For each translation that needs improvement, provide:
158
178
  1. The issue (brief)
159
179
  2. Suggested improvement
@@ -174,6 +194,11 @@ module Locallingo
174
194
  "\n#{config.context} terminology:\n#{lines.join("\n")}\n"
175
195
  end
176
196
 
197
+ def language_guide_section(locale)
198
+ guide = config.language_guide(locale)
199
+ guide.empty? ? "" : "\n#{guide}\n"
200
+ end
201
+
177
202
  def load_locale_translations(locale)
178
203
  translations = {}
179
204
  patterns = [
@@ -12,9 +12,9 @@ module Locallingo
12
12
  AR_ATTRIBUTES_PREFIX = "activerecord.attributes."
13
13
  AR_PREFIX = "activerecord."
14
14
 
15
- # +source+ is the flat source (en) hash. Returns :duplicate_value
16
- # violations naming both keys.
17
- def call(source:)
15
+ # +source+ is the flat source-locale hash; +locale+ is the configured source
16
+ # locale. Returns :duplicate_value violations naming both keys.
17
+ def call(source:, locale:)
18
18
  ar_keys_by_value = source
19
19
  .select { |key, _| key.start_with?(AR_ATTRIBUTES_PREFIX) }
20
20
  .group_by { |_key, value| value }
@@ -30,7 +30,7 @@ module Locallingo
30
30
  ar_dupes = ar_keys_by_value[value]
31
31
  {
32
32
  type: :duplicate_value,
33
- locale: "en",
33
+ locale:,
34
34
  key:,
35
35
  suggestion: "Value '#{value}' duplicates #{ar_dupes.join(", ")}. " \
36
36
  "Use #{ar_dupes.first} instead."
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Locallingo
4
- VERSION = "0.4.3"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/locallingo.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "locallingo/json_extraction"
7
7
  require_relative "locallingo/key_flattener"
8
8
  require_relative "locallingo/state_store"
9
9
  require_relative "locallingo/providers/ruby_llm"
10
+ require_relative "locallingo/batch_translator"
10
11
  require_relative "locallingo/validators/missing"
11
12
  require_relative "locallingo/validators/outdated"
12
13
  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.4.3
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '1.0'
18
+ version: '2.1'
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '3'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: '1.0'
28
+ version: '2.1'
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '3'
@@ -69,6 +69,7 @@ files:
69
69
  - config/locallingo.default.yml
70
70
  - exe/lingo
71
71
  - lib/locallingo.rb
72
+ - lib/locallingo/batch_translator.rb
72
73
  - lib/locallingo/cli.rb
73
74
  - lib/locallingo/configuration.rb
74
75
  - lib/locallingo/json_extraction.rb