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.
@@ -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