locallingo 0.1.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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +134 -0
- data/config/default.yml +47 -0
- data/config/locallingo.default.yml +70 -0
- data/exe/lingo +6 -0
- data/lib/locallingo/cli.rb +204 -0
- data/lib/locallingo/configuration.rb +161 -0
- data/lib/locallingo/json_extraction.rb +99 -0
- data/lib/locallingo/key_flattener.rb +114 -0
- data/lib/locallingo/manager.rb +376 -0
- data/lib/locallingo/providers/ruby_llm.rb +69 -0
- data/lib/locallingo/quality/british_spellings.rb +45 -0
- data/lib/locallingo/quality/static_rules.rb +97 -0
- data/lib/locallingo/quality/terminology.rb +78 -0
- data/lib/locallingo/quality_checker.rb +207 -0
- data/lib/locallingo/reporter.rb +147 -0
- data/lib/locallingo/rubocop.rb +21 -0
- data/lib/locallingo/state_store.rb +62 -0
- data/lib/locallingo/validators/duplicate_values.rb +42 -0
- data/lib/locallingo/validators/manual_edits.rb +39 -0
- data/lib/locallingo/validators/missing.rb +24 -0
- data/lib/locallingo/validators/outdated.rb +36 -0
- data/lib/locallingo/version.rb +5 -0
- data/lib/locallingo.rb +38 -0
- data/lib/rubocop/cop/locallingo/relative_i18n_key.rb +99 -0
- data/lib/rubocop/cop/locallingo/strftime_in_view.rb +51 -0
- metadata +118 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Locallingo
|
|
6
|
+
# Extracts a JSON object from an LLM response.
|
|
7
|
+
#
|
|
8
|
+
# Not every provider has a native "JSON-only" mode (Anthropic, unlike OpenAI's
|
|
9
|
+
# `response_format: json_object`, does not), so a model may wrap its object in
|
|
10
|
+
# a ```json fence or add a sentence of prose around it. This recovers the
|
|
11
|
+
# object from those shapes.
|
|
12
|
+
#
|
|
13
|
+
# The naive `text[/\{.*\}/m]` (greedy) is unsafe: a brace anywhere in the
|
|
14
|
+
# surrounding prose — very likely here, since the translation prompts are all
|
|
15
|
+
# about preserving `%{placeholder}`s the model may echo — extends the captured
|
|
16
|
+
# span past the real object and JSON.parse raises. So we try, in order: the
|
|
17
|
+
# whole string, a fenced block, then a brace-balanced scan from the first `{`
|
|
18
|
+
# that respects string literals and escapes.
|
|
19
|
+
module JsonExtraction
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Returns the parsed Hash, or raises JSON::ParserError if no JSON object can
|
|
23
|
+
# be recovered (or the top-level value is not an object).
|
|
24
|
+
def extract_object(content)
|
|
25
|
+
text = content.to_s.strip
|
|
26
|
+
|
|
27
|
+
parsed = try_parse(text) ||
|
|
28
|
+
try_parse(fenced_block(text)) ||
|
|
29
|
+
first_balanced_object(text) ||
|
|
30
|
+
JSON.parse(text) # final attempt; raises with a useful message
|
|
31
|
+
|
|
32
|
+
# Both callers treat the result as a key->value Hash, so reject a
|
|
33
|
+
# top-level array (or any non-object) here with a clear contract error
|
|
34
|
+
# rather than letting `.keys`/`.map` fail confusingly downstream.
|
|
35
|
+
return parsed if parsed.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
raise JSON::ParserError, "Expected a top-level JSON object, got #{parsed.class}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# nil on failure so the `||` chain falls through to the next strategy.
|
|
41
|
+
def try_parse(candidate)
|
|
42
|
+
return nil if candidate.nil?
|
|
43
|
+
|
|
44
|
+
JSON.parse(candidate)
|
|
45
|
+
rescue JSON::ParserError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fenced_block(text)
|
|
50
|
+
text[/```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```/m, 1]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Try each `{` in the text as a candidate object start (prose can contain
|
|
54
|
+
# stray braces — e.g. a `%{name}` placeholder echoed before the real JSON),
|
|
55
|
+
# and return the first balanced span that parses as a JSON object.
|
|
56
|
+
def first_balanced_object(text)
|
|
57
|
+
offset = text.index("{")
|
|
58
|
+
|
|
59
|
+
while offset
|
|
60
|
+
candidate = balanced_object(text, offset)
|
|
61
|
+
parsed = try_parse(candidate) if candidate
|
|
62
|
+
return parsed unless parsed.nil?
|
|
63
|
+
|
|
64
|
+
offset = text.index("{", offset + 1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Scan from `start` tracking brace depth, ignoring braces inside string
|
|
71
|
+
# literals (honoring backslash escapes), and return the substring up to the
|
|
72
|
+
# matching close brace (or nil if unbalanced).
|
|
73
|
+
def balanced_object(text, start)
|
|
74
|
+
depth = 0
|
|
75
|
+
in_string = false
|
|
76
|
+
escaped = false
|
|
77
|
+
|
|
78
|
+
text[start..].each_char.with_index do |char, index|
|
|
79
|
+
if in_string
|
|
80
|
+
if escaped then escaped = false
|
|
81
|
+
elsif char == "\\" then escaped = true
|
|
82
|
+
elsif char == '"' then in_string = false
|
|
83
|
+
end
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
case char
|
|
88
|
+
when '"' then in_string = true
|
|
89
|
+
when "{" then depth += 1
|
|
90
|
+
when "}"
|
|
91
|
+
depth -= 1
|
|
92
|
+
return text[start, index + 1] if depth.zero?
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
# Converts between nested locale hashes (as loaded from YAML) and the flat
|
|
5
|
+
# dotted-key representation the engine works in, and back.
|
|
6
|
+
#
|
|
7
|
+
# Flat keys use dot notation with bracket indices for arrays, e.g.
|
|
8
|
+
# `items[0].name`. This is a faithful extraction of the algorithms that lived
|
|
9
|
+
# in TranslationManager (flatten_hash / parse_key_segments / set_nested_value /
|
|
10
|
+
# navigate_or_create) so the round-trip behavior is byte-identical.
|
|
11
|
+
module KeyFlattener
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Flatten a nested hash to `{ "a.b.c" => "value" }`. Arrays are indexed with
|
|
15
|
+
# bracket notation. Only leaf values are kept; the caller decides which types
|
|
16
|
+
# to retain.
|
|
17
|
+
def flatten(hash, prefix = "")
|
|
18
|
+
result = {}
|
|
19
|
+
hash.each do |key, value|
|
|
20
|
+
full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
21
|
+
case value
|
|
22
|
+
when Hash
|
|
23
|
+
result.merge!(flatten(value, full_key))
|
|
24
|
+
when Array
|
|
25
|
+
value.each_with_index do |item, i|
|
|
26
|
+
if item.is_a?(String)
|
|
27
|
+
result["#{full_key}[#{i}]"] = item
|
|
28
|
+
elsif item.is_a?(Hash)
|
|
29
|
+
result.merge!(flatten(item, "#{full_key}[#{i}]"))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
else
|
|
33
|
+
result[full_key] = value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Set a flattened +key+ to +value+ inside the nested +hash+, creating
|
|
40
|
+
# intermediate hashes/arrays as needed.
|
|
41
|
+
def set_nested_value(hash, key, value)
|
|
42
|
+
segments = parse_key_segments(key)
|
|
43
|
+
current = hash
|
|
44
|
+
|
|
45
|
+
segments[0..-2].each do |segment|
|
|
46
|
+
current = navigate_or_create(current, segment)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
final_segment = segments.last
|
|
50
|
+
if final_segment[:index]
|
|
51
|
+
current[final_segment[:key]] ||= []
|
|
52
|
+
current[final_segment[:key]][final_segment[:index]] = value
|
|
53
|
+
else
|
|
54
|
+
current[final_segment[:key]] = value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Parse a flattened key into segments, handling both dot notation and bracket
|
|
59
|
+
# indices.
|
|
60
|
+
# "items[0].name" => [{key: "items", index: 0}, {key: "name", index: nil}]
|
|
61
|
+
# "matrix[0][1]" => [{key: "matrix", index: 0}, {key: nil, index: 1}]
|
|
62
|
+
def parse_key_segments(key)
|
|
63
|
+
segments = []
|
|
64
|
+
parts = key.split(".")
|
|
65
|
+
|
|
66
|
+
parts.each do |part|
|
|
67
|
+
if part =~ /^([^\[]*)\[(\d+)\](.*)$/
|
|
68
|
+
base_key = ::Regexp.last_match(1)
|
|
69
|
+
index = ::Regexp.last_match(2).to_i
|
|
70
|
+
remainder = ::Regexp.last_match(3)
|
|
71
|
+
|
|
72
|
+
segments << if base_key.empty?
|
|
73
|
+
{ key: nil, index: }
|
|
74
|
+
else
|
|
75
|
+
{ key: base_key, index: }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
while remainder =~ /^\[(\d+)\](.*)$/
|
|
79
|
+
segments << { key: nil, index: ::Regexp.last_match(1).to_i }
|
|
80
|
+
remainder = ::Regexp.last_match(2)
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
segments << { key: part, index: nil }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
segments
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Navigate to or create the appropriate container (hash or array) for a
|
|
91
|
+
# segment.
|
|
92
|
+
def navigate_or_create(current, segment)
|
|
93
|
+
if segment[:index]
|
|
94
|
+
if segment[:key]
|
|
95
|
+
current[segment[:key]] ||= []
|
|
96
|
+
ensure_array_size(current[segment[:key]], segment[:index])
|
|
97
|
+
current[segment[:key]][segment[:index]] ||= {}
|
|
98
|
+
current[segment[:key]][segment[:index]]
|
|
99
|
+
else
|
|
100
|
+
ensure_array_size(current, segment[:index])
|
|
101
|
+
current[segment[:index]] ||= {}
|
|
102
|
+
current[segment[:index]]
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
current[segment[:key]] ||= {}
|
|
106
|
+
current[segment[:key]]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def ensure_array_size(array, index)
|
|
111
|
+
array << nil while array.size <= index
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
require "logger"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
require_relative "configuration"
|
|
9
|
+
require_relative "key_flattener"
|
|
10
|
+
require_relative "state_store"
|
|
11
|
+
require_relative "providers/ruby_llm"
|
|
12
|
+
require_relative "validators/missing"
|
|
13
|
+
require_relative "validators/outdated"
|
|
14
|
+
require_relative "validators/duplicate_values"
|
|
15
|
+
require_relative "validators/manual_edits"
|
|
16
|
+
|
|
17
|
+
module Locallingo
|
|
18
|
+
# Manages translations with source-hash change detection.
|
|
19
|
+
#
|
|
20
|
+
# - Tracks source hashes to detect changes (drift)
|
|
21
|
+
# - Only translates missing/changed keys
|
|
22
|
+
# - Validates translation completeness (config-selected validators)
|
|
23
|
+
# - Merges LLM output back into the flat `<namespace>.<locale>.yml` files
|
|
24
|
+
#
|
|
25
|
+
# Everything app-specific (locales, provider/model, prompt context/glossary,
|
|
26
|
+
# per-language guides, which validators run) comes from the Configuration.
|
|
27
|
+
class Manager
|
|
28
|
+
MAX_RETRIES = 3
|
|
29
|
+
MAX_MISSING_RETRIES = 2
|
|
30
|
+
BASE_SLEEP_DURATION = 1.0
|
|
31
|
+
|
|
32
|
+
attr_reader :config, :dry_run, :verbose, :logger, :cli_name
|
|
33
|
+
|
|
34
|
+
def initialize(config: nil, root_path: nil, package: nil,
|
|
35
|
+
dry_run: false, verbose: false, logger: nil, cli_name: "lingo")
|
|
36
|
+
@config = config || Configuration.load(root_path: root_path || Dir.pwd, package:)
|
|
37
|
+
@dry_run = dry_run
|
|
38
|
+
@verbose = verbose
|
|
39
|
+
@cli_name = cli_name
|
|
40
|
+
@state = StateStore.new(@config.state_dir)
|
|
41
|
+
@provider = Providers::RubyLLM.new(provider: @config.provider)
|
|
42
|
+
@logger = logger || build_logger
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Current translation status per target locale.
|
|
46
|
+
def status
|
|
47
|
+
source = load_source_translations
|
|
48
|
+
|
|
49
|
+
config.target_locales.each_with_object({}) do |locale, results|
|
|
50
|
+
target = load_locale_translations(locale)
|
|
51
|
+
locale_state = @state.load(locale)
|
|
52
|
+
missing = source.keys - target.keys
|
|
53
|
+
outdated = outdated_validator.outdated_keys(source, locale_state)
|
|
54
|
+
|
|
55
|
+
results[locale] = {
|
|
56
|
+
total_keys: source.keys.size,
|
|
57
|
+
translated: target.keys.size,
|
|
58
|
+
missing: missing.size,
|
|
59
|
+
outdated: outdated.size,
|
|
60
|
+
missing_keys: missing.first(10),
|
|
61
|
+
outdated_keys: outdated.first(10)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Validate translations; returns an array of violation hashes. Which checks
|
|
67
|
+
# run is config-driven.
|
|
68
|
+
def validate
|
|
69
|
+
violations = []
|
|
70
|
+
source = load_source_translations
|
|
71
|
+
|
|
72
|
+
violations.concat(Validators::DuplicateValues.new.call(source:)) if config.validator_enabled?(:duplicate_values)
|
|
73
|
+
|
|
74
|
+
config.target_locales.each do |locale|
|
|
75
|
+
target = load_locale_translations(locale)
|
|
76
|
+
locale_state = @state.load(locale)
|
|
77
|
+
|
|
78
|
+
violations.concat(missing_validator.call(source:, target:, locale:)) if config.validator_enabled?(:missing)
|
|
79
|
+
if config.validator_enabled?(:outdated)
|
|
80
|
+
violations.concat(outdated_validator.call(source:, locale_state:, locale:))
|
|
81
|
+
end
|
|
82
|
+
if config.validator_enabled?(:manual_edits)
|
|
83
|
+
violations.concat(Validators::ManualEdits.new(cli_name:).call(target:, locale_state:, locale:))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
violations
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Translate missing/changed keys for one or all target locales.
|
|
91
|
+
def translate!(locale: nil, force: false, force_keys: [])
|
|
92
|
+
@provider.ensure_credentials!
|
|
93
|
+
|
|
94
|
+
locales_to_process = locale ? [locale] : config.target_locales
|
|
95
|
+
source = load_source_translations
|
|
96
|
+
|
|
97
|
+
locales_to_process.each { |target_locale| translate_locale(source, target_locale, force:, force_keys:) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Mark every current target value as intentional (source_hash + target_hash +
|
|
101
|
+
# manual flag) so the manual-edits validator stops flagging it.
|
|
102
|
+
def accept_edits!(locale: nil)
|
|
103
|
+
source = load_source_translations
|
|
104
|
+
locales = locale ? [locale] : config.target_locales
|
|
105
|
+
|
|
106
|
+
locales.each do |target_locale|
|
|
107
|
+
target = load_locale_translations(target_locale)
|
|
108
|
+
locale_state = @state.load(target_locale)
|
|
109
|
+
|
|
110
|
+
target.each do |key, value|
|
|
111
|
+
next unless source[key]
|
|
112
|
+
|
|
113
|
+
locale_state[key] = {
|
|
114
|
+
"source_hash" => @state.hash(source[key]),
|
|
115
|
+
"target_hash" => @state.hash(value),
|
|
116
|
+
"manual" => true
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@state.save(target_locale, locale_state) unless dry_run
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# CRC32 hash of the current source translations (change-detection fingerprint).
|
|
125
|
+
def source_hash
|
|
126
|
+
format("%08x", Zlib.crc32(load_source_translations.to_json))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Rewrite state from the current translation files (initial setup / after
|
|
130
|
+
# manual edits). Returns the combined state.
|
|
131
|
+
def sync_state!
|
|
132
|
+
source = load_source_translations
|
|
133
|
+
|
|
134
|
+
en_state = @state.load(config.source_locale)
|
|
135
|
+
source.each { |key, value| en_state[key] = { "source_hash" => @state.hash(value) } }
|
|
136
|
+
en_state.each_key { |key| en_state.delete(key) unless source.key?(key) }
|
|
137
|
+
@state.save(config.source_locale, en_state) unless dry_run
|
|
138
|
+
|
|
139
|
+
config.target_locales.each do |locale|
|
|
140
|
+
target = load_locale_translations(locale)
|
|
141
|
+
locale_state = @state.load(locale)
|
|
142
|
+
|
|
143
|
+
target.each_key do |key|
|
|
144
|
+
next unless source[key]
|
|
145
|
+
|
|
146
|
+
locale_state[key] = { "source_hash" => @state.hash(source[key]) }
|
|
147
|
+
end
|
|
148
|
+
locale_state.each_key { |key| locale_state.delete(key) unless target.key?(key) }
|
|
149
|
+
@state.save(locale, locale_state) unless dry_run
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
combined = { config.source_locale => @state.load(config.source_locale) }
|
|
153
|
+
config.target_locales.each { |locale| combined[locale] = @state.load(locale) }
|
|
154
|
+
combined
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Run the configured after_translate hook commands (from the app root).
|
|
158
|
+
def run_after_translate_hooks
|
|
159
|
+
config.after_translate.each do |command|
|
|
160
|
+
log("Running: #{command}")
|
|
161
|
+
Dir.chdir(config.root_path) { system(command) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def missing_validator = @missing_validator ||= Validators::Missing.new(cli_name:)
|
|
168
|
+
def outdated_validator = @outdated_validator ||= Validators::Outdated.new(cli_name:)
|
|
169
|
+
|
|
170
|
+
def translate_locale(source, target_locale, force:, force_keys:)
|
|
171
|
+
log("Processing #{target_locale}...")
|
|
172
|
+
|
|
173
|
+
target = load_locale_translations(target_locale)
|
|
174
|
+
locale_state = @state.load(target_locale)
|
|
175
|
+
exceptions = load_exceptions(target_locale)
|
|
176
|
+
|
|
177
|
+
keys = determine_keys_to_translate(source, target, locale_state, force:, force_keys:, exceptions:)
|
|
178
|
+
if keys.empty?
|
|
179
|
+
log(" No keys to translate for #{target_locale}")
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
log(" Translating #{keys.size} keys...")
|
|
184
|
+
translated, failed = translate_with_missing_retries(source, keys, target_locale)
|
|
185
|
+
|
|
186
|
+
successful = translated.except(*failed)
|
|
187
|
+
unless dry_run
|
|
188
|
+
merge_translations(target_locale, successful)
|
|
189
|
+
update_locale_state(source, locale_state, successful)
|
|
190
|
+
@state.save(target_locale, locale_state)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
log(" Completed #{target_locale}: #{successful.size} translated, #{failed.size} failed")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def translate_with_missing_retries(source, keys, target_locale)
|
|
197
|
+
translated, failed = translate_keys(source, keys, target_locale)
|
|
198
|
+
|
|
199
|
+
round = 0
|
|
200
|
+
while failed.any? && round < MAX_MISSING_RETRIES
|
|
201
|
+
round += 1
|
|
202
|
+
log(" Retry round #{round}: #{failed.size} keys remaining...")
|
|
203
|
+
sleep(BASE_SLEEP_DURATION * (2**round))
|
|
204
|
+
retried, failed = translate_keys(source, failed, target_locale)
|
|
205
|
+
translated.merge!(retried)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if failed.any?
|
|
209
|
+
log(" WARNING: #{failed.size} keys failed after all retries:", level: :warn)
|
|
210
|
+
failed.each { |key| log(" - #{key}", level: :warn) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
[translated, failed]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def translate_keys(source, keys, target_locale)
|
|
217
|
+
translations = {}
|
|
218
|
+
failed = []
|
|
219
|
+
|
|
220
|
+
keys.each_slice(config.batch_size) do |batch|
|
|
221
|
+
result = translate_batch(batch.to_h { |key| [key, source[key]] }, target_locale)
|
|
222
|
+
batch.each do |key|
|
|
223
|
+
if result.key?(key) && !result[key].to_s.empty?
|
|
224
|
+
translations[key] = result[key]
|
|
225
|
+
else
|
|
226
|
+
failed << key
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
sleep(BASE_SLEEP_DURATION)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
[translations, failed]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def translate_batch(payload, target_locale)
|
|
236
|
+
return {} if payload.empty?
|
|
237
|
+
|
|
238
|
+
retries = 0
|
|
239
|
+
begin
|
|
240
|
+
result = @provider.chat(
|
|
241
|
+
model: config.translate_model,
|
|
242
|
+
instructions: translation_prompt(target_locale),
|
|
243
|
+
payload:
|
|
244
|
+
)
|
|
245
|
+
log(" Batch translated: #{result.keys.size}/#{payload.keys.size} keys")
|
|
246
|
+
result
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
retries += 1
|
|
249
|
+
if retries < MAX_RETRIES
|
|
250
|
+
sleep_duration = BASE_SLEEP_DURATION * (2**retries)
|
|
251
|
+
log(" Batch failed (attempt #{retries}), retrying in #{sleep_duration}s: #{e.message}", level: :warn)
|
|
252
|
+
sleep(sleep_duration)
|
|
253
|
+
retry
|
|
254
|
+
end
|
|
255
|
+
log(" Translation batch failed after #{MAX_RETRIES} retries: #{e.message}", level: :error)
|
|
256
|
+
{}
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def translation_prompt(locale)
|
|
261
|
+
<<~PROMPT
|
|
262
|
+
Translate the following texts from #{config.language_name(config.source_locale)} to #{config.language_name(locale)}.
|
|
263
|
+
This is for #{config.context}.
|
|
264
|
+
|
|
265
|
+
## General Rules
|
|
266
|
+
- Preserve placeholders like #{config.placeholder_style} exactly as they appear
|
|
267
|
+
- Preserve HTML tags if present
|
|
268
|
+
- Use formal business language
|
|
269
|
+
- Keep translations concise - UI space is limited
|
|
270
|
+
#{glossary_section}#{language_guide_section(locale)}
|
|
271
|
+
Return ONLY a raw JSON object mapping each input key to its translation,
|
|
272
|
+
with no surrounding prose and no markdown code fences:
|
|
273
|
+
{"key1": "translated1", "key2": "translated2"}
|
|
274
|
+
PROMPT
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def glossary_section
|
|
278
|
+
return "" if config.glossary.empty?
|
|
279
|
+
|
|
280
|
+
lines = config.glossary.map { |term, meaning| " - \"#{term}\" = #{meaning}" }
|
|
281
|
+
"\n## Terminology\n#{lines.join("\n")}\n"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def language_guide_section(locale)
|
|
285
|
+
guide = config.language_guide(locale)
|
|
286
|
+
guide.empty? ? "" : "\n#{guide}\n"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def determine_keys_to_translate(source, target, locale_state, force:, force_keys:, exceptions:)
|
|
290
|
+
manual_keys = locale_state.select { |_k, v| v.is_a?(Hash) && v["manual"] }.keys.to_set
|
|
291
|
+
return source.keys - manual_keys.to_a if force
|
|
292
|
+
return force_keys & source.keys if force_keys.any?
|
|
293
|
+
|
|
294
|
+
excluded = exceptions.keys.to_set | manual_keys
|
|
295
|
+
missing = source.keys - target.keys - excluded.to_a
|
|
296
|
+
outdated = outdated_validator.outdated_keys(source, locale_state) - excluded.to_a
|
|
297
|
+
(missing + outdated).uniq
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def update_locale_state(source, locale_state, translations)
|
|
301
|
+
translations.each_key do |key|
|
|
302
|
+
next unless source[key]
|
|
303
|
+
|
|
304
|
+
locale_state[key] = {
|
|
305
|
+
"source_hash" => @state.hash(source[key]),
|
|
306
|
+
"target_hash" => @state.hash(translations[key])
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def merge_translations(locale, translations)
|
|
312
|
+
translations.group_by { |key, _| key.split(".").first }.each do |namespace, pairs|
|
|
313
|
+
file_path = find_or_create_locale_file(locale, namespace)
|
|
314
|
+
existing = File.exist?(file_path) ? YAML.load_file(file_path) : {}
|
|
315
|
+
existing[locale] ||= {}
|
|
316
|
+
pairs.each { |key, value| KeyFlattener.set_nested_value(existing[locale], key, value) }
|
|
317
|
+
File.write(file_path, existing.to_yaml)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def find_or_create_locale_file(locale, namespace)
|
|
322
|
+
pattern = File.join(config.locales_dir, "**", "#{namespace}.#{locale}.yml")
|
|
323
|
+
Dir.glob(pattern).min || File.join(config.locales_dir, "#{namespace}.#{locale}.yml")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def load_source_translations = load_locale_translations(config.source_locale)
|
|
327
|
+
|
|
328
|
+
def load_locale_translations(locale)
|
|
329
|
+
translations = {}
|
|
330
|
+
patterns = [
|
|
331
|
+
File.join(config.locales_dir, "**", "*.#{locale}.yml"),
|
|
332
|
+
File.join(config.locales_dir, "#{locale}.yml")
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
patterns.each do |pattern|
|
|
336
|
+
Dir.glob(pattern).each do |file|
|
|
337
|
+
content = YAML.load_file(file)
|
|
338
|
+
next unless content.is_a?(Hash) && content[locale]
|
|
339
|
+
|
|
340
|
+
KeyFlattener.flatten(content[locale]).each do |key, value|
|
|
341
|
+
translations[key] = value if value.is_a?(String)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
translations
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def load_exceptions(locale)
|
|
350
|
+
exception_file = File.join(config.exceptions_dir, "#{locale}.yml")
|
|
351
|
+
return {} unless File.exist?(exception_file)
|
|
352
|
+
|
|
353
|
+
content = YAML.load_file(exception_file)
|
|
354
|
+
return {} unless content.is_a?(Hash) && content[locale]
|
|
355
|
+
|
|
356
|
+
KeyFlattener.flatten(content[locale])
|
|
357
|
+
rescue StandardError => e
|
|
358
|
+
log(" Failed to load exceptions for #{locale}: #{e.message}", level: :warn)
|
|
359
|
+
{}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def build_logger
|
|
363
|
+
FileUtils.mkdir_p(config.state_dir)
|
|
364
|
+
logger = Logger.new(config.log_file, 5, 1_048_576)
|
|
365
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
|
366
|
+
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity}: #{msg}\n"
|
|
367
|
+
end
|
|
368
|
+
logger
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def log(message, level: :info)
|
|
372
|
+
logger&.public_send(level, message)
|
|
373
|
+
warn(message) if verbose
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../json_extraction"
|
|
4
|
+
|
|
5
|
+
module Locallingo
|
|
6
|
+
module Providers
|
|
7
|
+
# Thin wrapper over the `ruby_llm` gem. Every translation and quality-review
|
|
8
|
+
# call goes through here so the rest of the gem never touches a provider SDK
|
|
9
|
+
# directly and stays provider-agnostic.
|
|
10
|
+
#
|
|
11
|
+
# A fresh chat is created per call (`assume_model_exists: true` skips
|
|
12
|
+
# RubyLLM's registry lookup — the configured model id is the source of
|
|
13
|
+
# truth) so independent batches don't share conversation history. The
|
|
14
|
+
# response is parsed with the robust JsonExtraction extractor because not
|
|
15
|
+
# every provider guarantees fenceless JSON.
|
|
16
|
+
class RubyLLM
|
|
17
|
+
# Maps a RubyLLM provider symbol to the ENV var whose presence indicates
|
|
18
|
+
# credentials are available, so we can fail fast with a clear message
|
|
19
|
+
# before making a network call.
|
|
20
|
+
CREDENTIAL_ENV = {
|
|
21
|
+
openai: "OPENAI_API_KEY",
|
|
22
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
23
|
+
gemini: "GEMINI_API_KEY",
|
|
24
|
+
deepseek: "DEEPSEEK_API_KEY",
|
|
25
|
+
openrouter: "OPENROUTER_API_KEY"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :provider
|
|
29
|
+
|
|
30
|
+
def initialize(provider:)
|
|
31
|
+
@provider = provider.to_sym
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when credentials for the configured provider are present in ENV.
|
|
35
|
+
# Unknown providers are assumed configured (RubyLLM may source the key
|
|
36
|
+
# elsewhere) rather than blocking.
|
|
37
|
+
def credentials?
|
|
38
|
+
env = CREDENTIAL_ENV[provider]
|
|
39
|
+
return true unless env
|
|
40
|
+
|
|
41
|
+
!ENV.fetch(env, "").to_s.strip.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Raise a precise error when the provider has no credentials.
|
|
45
|
+
def ensure_credentials!
|
|
46
|
+
return if credentials?
|
|
47
|
+
|
|
48
|
+
raise MissingCredentialsError,
|
|
49
|
+
"No credentials for provider #{provider.inspect} " \
|
|
50
|
+
"(expected #{CREDENTIAL_ENV[provider]} in ENV)"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Send +instructions+ (system prompt) + +payload+ (user message, JSON) to
|
|
54
|
+
# the model and return the parsed JSON object as a Hash.
|
|
55
|
+
def chat(model:, instructions:, payload:)
|
|
56
|
+
require "ruby_llm"
|
|
57
|
+
|
|
58
|
+
conversation = ::RubyLLM.chat(
|
|
59
|
+
model:,
|
|
60
|
+
provider:,
|
|
61
|
+
assume_model_exists: true
|
|
62
|
+
).with_instructions(instructions)
|
|
63
|
+
|
|
64
|
+
response = conversation.ask(JSON.pretty_generate(payload))
|
|
65
|
+
JsonExtraction.extract_object(response.content)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
module Quality
|
|
5
|
+
# American -> British spelling drift, applied to the source locale only.
|
|
6
|
+
# Opt-in via `quality.british_spellings: true`. Ported from zazu/app.
|
|
7
|
+
module BritishSpellings
|
|
8
|
+
SPELLINGS = {
|
|
9
|
+
"organization" => "organisation",
|
|
10
|
+
"color" => "colour",
|
|
11
|
+
"center" => "centre",
|
|
12
|
+
"favor" => "favour",
|
|
13
|
+
"honor" => "honour",
|
|
14
|
+
"labor" => "labour",
|
|
15
|
+
"analyze" => "analyse",
|
|
16
|
+
"optimize" => "optimise",
|
|
17
|
+
"recognize" => "recognise",
|
|
18
|
+
"realize" => "realise",
|
|
19
|
+
"apologize" => "apologise",
|
|
20
|
+
"authorize" => "authorise",
|
|
21
|
+
"personalize" => "personalise",
|
|
22
|
+
"familiarize" => "familiarise"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Auto-fixable British-spelling suggestions for one key/text pair.
|
|
28
|
+
def check(key, text, locale)
|
|
29
|
+
SPELLINGS.filter_map do |american, british|
|
|
30
|
+
next unless text.match?(/\b#{Regexp.escape(american)}\b/i)
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
key:, text:, locale:,
|
|
34
|
+
category: :british_spelling,
|
|
35
|
+
issue: "Use British spelling '#{british}' instead of '#{american}'",
|
|
36
|
+
match: american,
|
|
37
|
+
fix: { from: american, to: british },
|
|
38
|
+
severity: :warning,
|
|
39
|
+
source: :static
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|