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 +4 -4
- data/CHANGELOG.md +53 -3
- data/README.md +2 -0
- data/config/locallingo.default.yml +5 -1
- data/lib/locallingo/batch_translator.rb +128 -0
- data/lib/locallingo/cli.rb +44 -10
- data/lib/locallingo/configuration.rb +2 -1
- data/lib/locallingo/json_extraction.rb +14 -2
- data/lib/locallingo/manager.rb +69 -78
- data/lib/locallingo/providers/ruby_llm.rb +6 -1
- data/lib/locallingo/quality_checker.rb +31 -6
- data/lib/locallingo/validators/duplicate_values.rb +4 -4
- data/lib/locallingo/version.rb +1 -1
- data/lib/locallingo.rb +1 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4296369c42c6c23ce6a15be0cc51adf0976fd6c07fd6b4514cd1a945c7ec07f9
|
|
4
|
+
data.tar.gz: b842069155d70be0a3291f67637deb719096b3654825768265cc8007e9ae27c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
data/lib/locallingo/cli.rb
CHANGED
|
@@ -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
|
|
109
|
-
opts.on("--
|
|
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
|
|
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],
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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(
|
|
29
|
+
try_parse(fenced) ||
|
|
29
30
|
first_balanced_object(text) ||
|
|
30
|
-
|
|
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
|
data/lib/locallingo/manager.rb
CHANGED
|
@@ -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 =
|
|
29
|
-
MAX_MISSING_RETRIES =
|
|
30
|
-
BASE_SLEEP_DURATION =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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)}
|
|
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
|
|
353
|
-
#
|
|
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
|
|
35
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
|
33
|
+
locale:,
|
|
34
34
|
key:,
|
|
35
35
|
suggestion: "Value '#{value}' duplicates #{ar_dupes.join(", ")}. " \
|
|
36
36
|
"Use #{ar_dupes.first} instead."
|
data/lib/locallingo/version.rb
CHANGED
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
|
+
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
|
|
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
|
|
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
|