locallingo 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +106 -1
- data/config/locallingo.default.yml +6 -0
- data/lib/locallingo/active_record.rb +167 -0
- data/lib/locallingo/batch_translator.rb +26 -3
- data/lib/locallingo/configuration.rb +40 -18
- data/lib/locallingo/content_leaves.rb +40 -0
- data/lib/locallingo/drift.rb +93 -0
- data/lib/locallingo/manager.rb +16 -59
- data/lib/locallingo/prompt.rb +73 -0
- data/lib/locallingo/providers/ruby_llm.rb +43 -26
- data/lib/locallingo/quality/static_rules.rb +6 -5
- data/lib/locallingo/quality/terminology.rb +1 -1
- data/lib/locallingo/record_translator.rb +94 -0
- data/lib/locallingo/rubocop.rb +1 -1
- data/lib/locallingo/settings.rb +12 -1
- data/lib/locallingo/translator.rb +70 -0
- data/lib/locallingo/validators/manual_edits.rb +4 -8
- data/lib/locallingo/validators/outdated.rb +5 -5
- data/lib/locallingo/version.rb +1 -1
- data/lib/locallingo.rb +50 -6
- metadata +7 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "state_store"
|
|
5
|
+
|
|
6
|
+
module Locallingo
|
|
7
|
+
# Missing / outdated / hand-edited decisions over one value and its state
|
|
8
|
+
# entry — the rule locale files and database records share.
|
|
9
|
+
#
|
|
10
|
+
# An entry is the shape StateStore writes:
|
|
11
|
+
# { "source_hash" => …, "target_hash" => …, "manual" => true }
|
|
12
|
+
#
|
|
13
|
+
# Values may be a String or a nested Hash/Array of strings. A String hashes
|
|
14
|
+
# raw (exactly like StateStore, so existing state files stay valid); nested
|
|
15
|
+
# values hash over a canonical JSON form with sorted keys, so a record can
|
|
16
|
+
# track one entry per field or one per whole value.
|
|
17
|
+
class Drift
|
|
18
|
+
def initialize(hasher: StateStore.method(:hash))
|
|
19
|
+
@hasher = hasher
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def digest(value)
|
|
23
|
+
case value
|
|
24
|
+
when Hash, Array then @hasher.call(JSON.generate(canonical(value)))
|
|
25
|
+
else @hasher.call(value.to_s)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A new entry for a target translated (or written by hand) from +source+.
|
|
30
|
+
def record(source:, target:, manual: false)
|
|
31
|
+
entry = { "source_hash" => digest(source), "target_hash" => digest(target) }
|
|
32
|
+
entry["manual"] = true if manual
|
|
33
|
+
entry
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# :missing (blank target), :outdated (source changed since the entry was
|
|
37
|
+
# recorded) or :current. The manual flag is not considered here; #due?
|
|
38
|
+
# applies it.
|
|
39
|
+
def status(source:, target:, entry:)
|
|
40
|
+
return :missing if blank?(target)
|
|
41
|
+
return :outdated if outdated?(source:, entry:)
|
|
42
|
+
|
|
43
|
+
:current
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The source changed since +entry+ recorded it. An untracked value (no
|
|
47
|
+
# entry or no source_hash) is not outdated.
|
|
48
|
+
def outdated?(source:, entry:)
|
|
49
|
+
stored = entry.is_a?(Hash) ? entry["source_hash"] : nil
|
|
50
|
+
!stored.nil? && stored != digest(source)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A person wrote the target: it must not be machine-overwritten.
|
|
54
|
+
def manual?(entry) = entry.is_a?(Hash) && entry["manual"] ? true : false
|
|
55
|
+
|
|
56
|
+
# The target changed since it was recorded and is not yet flagged manual.
|
|
57
|
+
def hand_edited?(target:, entry:)
|
|
58
|
+
return false unless entry.is_a?(Hash) && !manual?(entry)
|
|
59
|
+
|
|
60
|
+
stored = entry["target_hash"]
|
|
61
|
+
!stored.nil? && stored != digest(target)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether the target should be (re)translated: missing or outdated, every
|
|
65
|
+
# value with +force+, and never a manual one unless +include_manual+.
|
|
66
|
+
def due?(source:, target:, entry:, force: false, include_manual: false)
|
|
67
|
+
return false if blank?(source)
|
|
68
|
+
return false if manual?(entry) && !include_manual
|
|
69
|
+
return true if force
|
|
70
|
+
|
|
71
|
+
status(source:, target:, entry:) != :current
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Empty, whitespace-only, or a Hash/Array with only blank leaves.
|
|
75
|
+
def blank?(value)
|
|
76
|
+
case value
|
|
77
|
+
when Hash then value.values.all? { |item| blank?(item) }
|
|
78
|
+
when Array then value.all? { |item| blank?(item) }
|
|
79
|
+
else value.to_s.strip.empty?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def canonical(value)
|
|
86
|
+
case value
|
|
87
|
+
when Hash then value.map { |key, item| [key.to_s, canonical(item)] }.sort_by(&:first).to_h
|
|
88
|
+
when Array then value.map { |item| canonical(item) }
|
|
89
|
+
else value
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/locallingo/manager.rb
CHANGED
|
@@ -8,8 +8,10 @@ require "fileutils"
|
|
|
8
8
|
require_relative "configuration"
|
|
9
9
|
require_relative "key_flattener"
|
|
10
10
|
require_relative "state_store"
|
|
11
|
+
require_relative "drift"
|
|
11
12
|
require_relative "providers/ruby_llm"
|
|
12
13
|
require_relative "batch_translator"
|
|
14
|
+
require_relative "prompt"
|
|
13
15
|
require_relative "validators/missing"
|
|
14
16
|
require_relative "validators/outdated"
|
|
15
17
|
require_relative "validators/duplicate_values"
|
|
@@ -39,6 +41,7 @@ module Locallingo
|
|
|
39
41
|
@verbose = verbose
|
|
40
42
|
@cli_name = cli_name
|
|
41
43
|
@state = StateStore.new(@config.state_dir)
|
|
44
|
+
@drift = Drift.new
|
|
42
45
|
@provider = Providers::RubyLLM.new(provider: @config.provider)
|
|
43
46
|
@logger = logger || build_logger
|
|
44
47
|
end
|
|
@@ -127,11 +130,7 @@ module Locallingo
|
|
|
127
130
|
|
|
128
131
|
plans.each_with_object({}) do |(target_locale, target, locale_state, accepted), results|
|
|
129
132
|
accepted.each do |key|
|
|
130
|
-
locale_state[key] =
|
|
131
|
-
"source_hash" => @state.hash(source[key]),
|
|
132
|
-
"target_hash" => @state.hash(target[key]),
|
|
133
|
-
"manual" => true
|
|
134
|
-
}
|
|
133
|
+
locale_state[key] = @drift.record(source: source[key], target: target[key], manual: true)
|
|
135
134
|
end
|
|
136
135
|
@state.save(target_locale, locale_state) unless dry_run
|
|
137
136
|
results[target_locale] = accepted
|
|
@@ -164,11 +163,12 @@ module Locallingo
|
|
|
164
163
|
combined
|
|
165
164
|
end
|
|
166
165
|
|
|
167
|
-
# Run the configured after_translate hook commands (from the app root
|
|
166
|
+
# Run the configured after_translate hook commands (from the app root, in
|
|
167
|
+
# the child process only: the process-wide cwd is never changed).
|
|
168
168
|
def run_after_translate_hooks
|
|
169
169
|
config.after_translate.each do |command|
|
|
170
170
|
log("Running: #{command}")
|
|
171
|
-
|
|
171
|
+
system(command, chdir: config.root_path)
|
|
172
172
|
end
|
|
173
173
|
end
|
|
174
174
|
|
|
@@ -179,7 +179,8 @@ module Locallingo
|
|
|
179
179
|
|
|
180
180
|
def batch_translator
|
|
181
181
|
@batch_translator ||= BatchTranslator.new(
|
|
182
|
-
provider: @provider, model: config.translate_model, batch_size: config.batch_size,
|
|
182
|
+
provider: @provider, model: config.translate_model, batch_size: config.batch_size,
|
|
183
|
+
max_batch_chars: config.max_batch_chars, pause: config.pause, log: method(:log)
|
|
183
184
|
)
|
|
184
185
|
end
|
|
185
186
|
|
|
@@ -208,10 +209,7 @@ module Locallingo
|
|
|
208
209
|
return target.keys.select { |key| source.key?(key) } if all
|
|
209
210
|
|
|
210
211
|
target.keys.select do |key|
|
|
211
|
-
entry
|
|
212
|
-
next false unless source.key?(key) && entry.is_a?(Hash) && !entry["manual"]
|
|
213
|
-
|
|
214
|
-
entry["target_hash"] && entry["target_hash"] != @state.hash(target[key])
|
|
212
|
+
source.key?(key) && @drift.hand_edited?(target: target[key], entry: locale_state[key])
|
|
215
213
|
end
|
|
216
214
|
end
|
|
217
215
|
|
|
@@ -261,47 +259,10 @@ module Locallingo
|
|
|
261
259
|
failed
|
|
262
260
|
end
|
|
263
261
|
|
|
264
|
-
def translation_prompt(locale)
|
|
265
|
-
language = config.language_name(locale)
|
|
266
|
-
<<~PROMPT
|
|
267
|
-
Translate the following texts from #{config.language_name(config.source_locale)} into natural, idiomatic #{language}.
|
|
268
|
-
This is for #{config.context}.
|
|
269
|
-
|
|
270
|
-
## Idiomatic Translation
|
|
271
|
-
- Translate the meaning, not word for word: write what a native #{language} speaker would actually say in this situation
|
|
272
|
-
- Use the phrasing, word order and UI conventions that are standard in #{language} software, even when they differ from the source
|
|
273
|
-
- Replace source-language idioms and set phrases with their natural #{language} equivalent instead of translating them literally
|
|
274
|
-
- Avoid calques and anglicisms when an established #{language} expression exists
|
|
275
|
-
- Never add, drop or change information to make a sentence sound natural
|
|
276
|
-
- Terminology and any language guide below take precedence over these style rules
|
|
277
|
-
|
|
278
|
-
## General Rules
|
|
279
|
-
- Preserve placeholders like #{config.placeholder_style} exactly as they appear
|
|
280
|
-
- Preserve HTML tags if present
|
|
281
|
-
- Keep translations concise - UI space is limited
|
|
282
|
-
- Escape every double quote (") inside a translated value as \\" so the reply stays valid JSON
|
|
283
|
-
- Keep typographic quotes (“ ” „ ‚ « ») exactly as they appear in the source
|
|
284
|
-
#{glossary_section}#{language_guide_section(locale)}
|
|
285
|
-
Return ONLY a raw JSON object mapping each input key to its translation,
|
|
286
|
-
with no surrounding prose and no markdown code fences:
|
|
287
|
-
{"key1": "translated1", "key2": "translated2"}
|
|
288
|
-
PROMPT
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def glossary_section
|
|
292
|
-
return "" if config.glossary.empty?
|
|
293
|
-
|
|
294
|
-
lines = config.glossary.map { |term, meaning| " - \"#{term}\" = #{meaning}" }
|
|
295
|
-
"\n## Terminology\n#{lines.join("\n")}\n"
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def language_guide_section(locale)
|
|
299
|
-
guide = config.language_guide(locale)
|
|
300
|
-
guide.empty? ? "" : "\n#{guide}\n"
|
|
301
|
-
end
|
|
262
|
+
def translation_prompt(locale) = Prompt.translation(config:, from: config.source_locale, to: locale)
|
|
302
263
|
|
|
303
264
|
def determine_keys_to_translate(source, target, locale_state, force:, force_keys:, exceptions:)
|
|
304
|
-
manual_keys = locale_state.select { |
|
|
265
|
+
manual_keys = locale_state.select { |_key, entry| @drift.manual?(entry) }.keys.to_set
|
|
305
266
|
return source.keys - manual_keys.to_a if force
|
|
306
267
|
return force_keys & source.keys if force_keys.any?
|
|
307
268
|
|
|
@@ -314,7 +275,7 @@ module Locallingo
|
|
|
314
275
|
# Drops manual keys from the named force_keys (with a loud warning) unless
|
|
315
276
|
# the caller opted in to overwriting them.
|
|
316
277
|
def resolve_force_keys(source, force_keys, locale_state, include_manual:, locale:)
|
|
317
|
-
manual = (force_keys & source.keys).select { |key|
|
|
278
|
+
manual = (force_keys & source.keys).select { |key| @drift.manual?(locale_state[key]) }
|
|
318
279
|
return force_keys if manual.empty?
|
|
319
280
|
|
|
320
281
|
if include_manual
|
|
@@ -335,15 +296,11 @@ module Locallingo
|
|
|
335
296
|
translations.each_key do |key|
|
|
336
297
|
next unless source[key]
|
|
337
298
|
|
|
338
|
-
existing = locale_state[key]
|
|
339
|
-
entry = {
|
|
340
|
-
"source_hash" => @state.hash(source[key]),
|
|
341
|
-
"target_hash" => @state.hash(translations[key])
|
|
342
|
-
}
|
|
343
299
|
# A force-keyed retranslation overwrites a manual value only with
|
|
344
300
|
# include_manual, and even then the protection flag must survive.
|
|
345
|
-
|
|
346
|
-
|
|
301
|
+
locale_state[key] = @drift.record(
|
|
302
|
+
source: source[key], target: translations[key], manual: @drift.manual?(locale_state[key])
|
|
303
|
+
)
|
|
347
304
|
end
|
|
348
305
|
end
|
|
349
306
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
# Builds the translation system prompt: idiomatic-translation and formatting
|
|
5
|
+
# rules, the configured glossary and per-language guide, plus optional caller
|
|
6
|
+
# instructions.
|
|
7
|
+
#
|
|
8
|
+
# Styles:
|
|
9
|
+
# - `:ui` — short interface strings (the CLI's prompt, pinned by a spec)
|
|
10
|
+
# - `:prose` — long-form content: Markdown structure is kept and there is no
|
|
11
|
+
# brevity rule
|
|
12
|
+
module Prompt
|
|
13
|
+
STYLES = %i[ui prose].freeze
|
|
14
|
+
|
|
15
|
+
BREVITY_RULE = "- Keep translations concise - UI space is limited\n"
|
|
16
|
+
|
|
17
|
+
MARKDOWN_RULES = <<~RULES
|
|
18
|
+
- Keep the Markdown structure: headings, lists, emphasis, links and line breaks stay where they are
|
|
19
|
+
- Translate link text, never the link target (URL)
|
|
20
|
+
- Never merge or split values: every key gets exactly one translated value
|
|
21
|
+
RULES
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def translation(config:, from:, to:, style: :ui, instructions: nil)
|
|
26
|
+
raise Error, "Unknown prompt style #{style.inspect} (expected one of #{STYLES.join(", ")})" unless
|
|
27
|
+
STYLES.include?(style.to_s.to_sym)
|
|
28
|
+
|
|
29
|
+
language = config.language_name(to)
|
|
30
|
+
<<~PROMPT
|
|
31
|
+
Translate the following texts from #{config.language_name(from)} into natural, idiomatic #{language}.
|
|
32
|
+
This is for #{config.context}.
|
|
33
|
+
|
|
34
|
+
## Idiomatic Translation
|
|
35
|
+
- Translate the meaning, not word for word: write what a native #{language} speaker would actually say in this situation
|
|
36
|
+
- Use the phrasing, word order and UI conventions that are standard in #{language} software, even when they differ from the source
|
|
37
|
+
- Replace source-language idioms and set phrases with their natural #{language} equivalent instead of translating them literally
|
|
38
|
+
- Avoid calques and anglicisms when an established #{language} expression exists
|
|
39
|
+
- Never add, drop or change information to make a sentence sound natural
|
|
40
|
+
- Terminology and any language guide below take precedence over these style rules
|
|
41
|
+
|
|
42
|
+
## General Rules
|
|
43
|
+
- Preserve placeholders like #{config.placeholder_style} exactly as they appear
|
|
44
|
+
- Preserve HTML tags if present
|
|
45
|
+
#{style.to_sym == :prose ? MARKDOWN_RULES : BREVITY_RULE}- Escape every double quote (") inside a translated value as \\" so the reply stays valid JSON
|
|
46
|
+
- Keep typographic quotes (“ ” „ ‚ « ») exactly as they appear in the source
|
|
47
|
+
#{glossary_section(config)}#{language_guide_section(config, to)}#{instructions_section(instructions)}
|
|
48
|
+
Return ONLY a raw JSON object mapping each input key to its translation,
|
|
49
|
+
with no surrounding prose and no markdown code fences:
|
|
50
|
+
{"key1": "translated1", "key2": "translated2"}
|
|
51
|
+
PROMPT
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def glossary_section(config)
|
|
55
|
+
return "" if config.glossary.empty?
|
|
56
|
+
|
|
57
|
+
lines = config.glossary.map { |term, meaning| " - \"#{term}\" = #{meaning}" }
|
|
58
|
+
"\n## Terminology\n#{lines.join("\n")}\n"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def language_guide_section(config, locale)
|
|
62
|
+
guide = config.language_guide(locale)
|
|
63
|
+
guide.empty? ? "" : "\n#{guide}\n"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def instructions_section(instructions)
|
|
67
|
+
text = instructions.to_s.strip
|
|
68
|
+
text.empty? ? "" : "\n## Additional Instructions\n#{text}\n"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private_class_method :glossary_section, :language_guide_section, :instructions_section
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -18,6 +18,12 @@ module Locallingo
|
|
|
18
18
|
# prompt is built once per locale and reused by every batch, so providers
|
|
19
19
|
# that support caching (Anthropic, OpenAI-compatible) bill the repeated
|
|
20
20
|
# prefix at the cached rate.
|
|
21
|
+
#
|
|
22
|
+
# Every chat runs on a `RubyLLM::Context`: the one given
|
|
23
|
+
# (`RubyLLM.context { |c| c.anthropic_api_key = … }`), else a fresh one built
|
|
24
|
+
# for that chat from the resolved key. The global `RubyLLM.config` is never
|
|
25
|
+
# written, so translators with different keys (one per tenant) can run
|
|
26
|
+
# concurrently.
|
|
21
27
|
class RubyLLM
|
|
22
28
|
# Maps a RubyLLM provider symbol to the ENV var whose presence indicates
|
|
23
29
|
# credentials are available, so we can fail fast with a clear message
|
|
@@ -30,10 +36,11 @@ module Locallingo
|
|
|
30
36
|
openrouter: "OPENROUTER_API_KEY"
|
|
31
37
|
}.freeze
|
|
32
38
|
|
|
33
|
-
attr_reader :provider
|
|
39
|
+
attr_reader :provider, :context
|
|
34
40
|
|
|
35
|
-
def initialize(provider:)
|
|
41
|
+
def initialize(provider:, context: nil)
|
|
36
42
|
@provider = provider.to_sym
|
|
43
|
+
@context = context
|
|
37
44
|
end
|
|
38
45
|
|
|
39
46
|
# True when a key for the configured provider is found in any source
|
|
@@ -50,6 +57,12 @@ module Locallingo
|
|
|
50
57
|
def ensure_credentials!
|
|
51
58
|
return if credentials?
|
|
52
59
|
|
|
60
|
+
if context
|
|
61
|
+
raise MissingCredentialsError,
|
|
62
|
+
"No credentials for provider #{provider.inspect}: the given RubyLLM context " \
|
|
63
|
+
"has no #{key_setting}."
|
|
64
|
+
end
|
|
65
|
+
|
|
53
66
|
raise MissingCredentialsError,
|
|
54
67
|
"No credentials for provider #{provider.inspect}. " \
|
|
55
68
|
"Set #{CREDENTIAL_ENV[provider]} in ENV, call " \
|
|
@@ -61,46 +74,50 @@ module Locallingo
|
|
|
61
74
|
# the model and return the parsed JSON object as a Hash.
|
|
62
75
|
def chat(model:, instructions:, payload:)
|
|
63
76
|
require "ruby_llm"
|
|
64
|
-
configure_credentials!
|
|
65
|
-
|
|
66
|
-
conversation = ::RubyLLM.chat(
|
|
67
|
-
model:,
|
|
68
|
-
provider:,
|
|
69
|
-
assume_model_exists: true
|
|
70
|
-
).with_instructions(instructions, cache_until_here: true)
|
|
71
77
|
|
|
78
|
+
conversation = new_chat(model).with_instructions(instructions, cache_until_here: true)
|
|
72
79
|
response = conversation.ask(JSON.pretty_generate(payload))
|
|
73
80
|
JsonExtraction.extract_object(response.content)
|
|
74
81
|
end
|
|
75
82
|
|
|
76
83
|
private
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# key into RubyLLM's config: an explicit `Locallingo.configure` key wins
|
|
82
|
-
# (re-resolved every chat so callables stay live), then a key the host
|
|
83
|
-
# app already set, then the ENV fallback.
|
|
84
|
-
def configure_credentials!
|
|
85
|
-
return unless CREDENTIAL_ENV.key?(provider)
|
|
86
|
-
|
|
87
|
-
config = ::RubyLLM.config
|
|
88
|
-
setting = key_setting
|
|
89
|
-
return unless config.respond_to?(setting) && config.respond_to?("#{setting}=")
|
|
90
|
-
|
|
91
|
-
key = settings_api_key
|
|
92
|
-
return if key.nil? && !presence(config.public_send(setting)).nil?
|
|
85
|
+
def new_chat(model)
|
|
86
|
+
(context || chat_context).chat(model:, provider:, assume_model_exists: true)
|
|
87
|
+
end
|
|
93
88
|
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
# A context of its own for one chat. `RubyLLM.context` copies the global
|
|
90
|
+
# config, so a key the host app set via RubyLLM.configure carries over;
|
|
91
|
+
# an explicit `Locallingo.configure` key wins over it (re-resolved every
|
|
92
|
+
# chat so callables stay live), and ENV fills in when neither is set
|
|
93
|
+
# (RubyLLM does not read provider keys from ENV on its own, so a
|
|
94
|
+
# standalone CLI run would otherwise raise "Missing configuration").
|
|
95
|
+
def chat_context
|
|
96
|
+
::RubyLLM.context do |config|
|
|
97
|
+
setting = key_setting
|
|
98
|
+
next unless CREDENTIAL_ENV.key?(provider) && config.respond_to?("#{setting}=")
|
|
99
|
+
|
|
100
|
+
key = settings_api_key
|
|
101
|
+
key ||= env_api_key if presence(config.public_send(setting)).nil?
|
|
102
|
+
config.public_send("#{setting}=", key) unless key.nil?
|
|
103
|
+
end
|
|
96
104
|
end
|
|
97
105
|
|
|
98
106
|
# Key resolution across sources, in precedence order. Used by
|
|
99
107
|
# #credentials? as a fail-fast check before any network call.
|
|
100
108
|
def resolved_api_key
|
|
109
|
+
return context_api_key if context
|
|
110
|
+
|
|
101
111
|
settings_api_key || host_configured_api_key || env_api_key
|
|
102
112
|
end
|
|
103
113
|
|
|
114
|
+
# With a context, its own config is the only source: the chat never
|
|
115
|
+
# reads the settings, the global config or ENV.
|
|
116
|
+
def context_api_key
|
|
117
|
+
config = context.config
|
|
118
|
+
config.respond_to?(key_setting) ? presence(config.public_send(key_setting)) : nil
|
|
119
|
+
end
|
|
120
|
+
|
|
104
121
|
def settings_api_key
|
|
105
122
|
Locallingo.settings.api_key_for(provider)
|
|
106
123
|
end
|
|
@@ -11,6 +11,7 @@ module Locallingo
|
|
|
11
11
|
"Can not" => "Cannot"
|
|
12
12
|
}.freeze
|
|
13
13
|
|
|
14
|
+
# Every level frozen, so any Ractor can read the rules.
|
|
14
15
|
RULES = {
|
|
15
16
|
terminology: {
|
|
16
17
|
/\bcan not\b/i => "Use 'cannot' (one word)",
|
|
@@ -18,11 +19,11 @@ module Locallingo
|
|
|
18
19
|
"Use 'log in' (verb) not 'login' here",
|
|
19
20
|
/\bclick\s+here\b/i => "Avoid 'click here' - use descriptive link text",
|
|
20
21
|
/\bplease\b.*\bplease\b/i => "Multiple 'please' in same text - remove redundancy"
|
|
21
|
-
},
|
|
22
|
+
}.freeze,
|
|
22
23
|
placeholders: {
|
|
23
24
|
/%\{[^}]*\s[^}]*\}/ => "Placeholder contains spaces - may cause issues",
|
|
24
25
|
/%\{[A-Z]/ => "Placeholder starts with uppercase - convention is lowercase"
|
|
25
|
-
},
|
|
26
|
+
}.freeze,
|
|
26
27
|
clarity: {
|
|
27
28
|
/\betc\.?\b/i => "Avoid 'etc.' - be specific or use 'and more'",
|
|
28
29
|
/\bstuff\b/i => "Vague word 'stuff' - be more specific",
|
|
@@ -30,19 +31,19 @@ module Locallingo
|
|
|
30
31
|
/\basap\b/i => "Avoid abbreviation 'ASAP' - use 'as soon as possible'",
|
|
31
32
|
/\bfyi\b/i => "Avoid abbreviation 'FYI' - rephrase",
|
|
32
33
|
/\s{2,}/ => "Multiple consecutive spaces"
|
|
33
|
-
},
|
|
34
|
+
}.freeze,
|
|
34
35
|
business: {
|
|
35
36
|
/\bsorry\b/i => "Consider 'We apologize' for formal business tone",
|
|
36
37
|
/\boops\b/i => "Informal - use professional error messaging",
|
|
37
38
|
/\buh\s*oh\b/i => "Informal - use professional error messaging",
|
|
38
39
|
/\bawesome\b/i => "Consider more professional alternatives",
|
|
39
40
|
/\bcool\b(?!\s*down)/i => "Informal - consider 'great' or 'excellent'"
|
|
40
|
-
},
|
|
41
|
+
}.freeze,
|
|
41
42
|
accessibility: {
|
|
42
43
|
/\bsee\s+below\b/i => "Screen reader unfriendly - describe the content",
|
|
43
44
|
/\babove\b/i => "Positional reference - may not work for all users",
|
|
44
45
|
/\bred\b.*\berror\b|\berror\b.*\bred\b/i => "Don't rely on color alone for meaning"
|
|
45
|
-
}
|
|
46
|
+
}.freeze
|
|
46
47
|
}.freeze
|
|
47
48
|
|
|
48
49
|
module_function
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
# Translates records that include Locallingo::Translatable (see
|
|
5
|
+
# `locallingo/active_record`) into one target locale.
|
|
6
|
+
#
|
|
7
|
+
# Locallingo::RecordTranslator.new(translator:)
|
|
8
|
+
# .call(Article.where(id: ids), to: "de", style: :prose)
|
|
9
|
+
# # => { translated: 3, failed: { article_id => [paths] }, skipped: [ids] }
|
|
10
|
+
#
|
|
11
|
+
# The due records' segments (keys prefixed by record id) go out as one
|
|
12
|
+
# batched translation per source locale. Each record is then written under
|
|
13
|
+
# its row lock, after re-checking that its source is unchanged and the
|
|
14
|
+
# target is still due: a source edited or a target hand-written while the
|
|
15
|
+
# model answered is skipped and left for the next run. A record with any
|
|
16
|
+
# failed segment is not written; it stays due and its paths are reported.
|
|
17
|
+
#
|
|
18
|
+
# Records must be persisted and of one model (ids are the key prefix). This
|
|
19
|
+
# class never loads ActiveRecord itself; records only need the concern.
|
|
20
|
+
class RecordTranslator
|
|
21
|
+
# A capture-free lambda, made shareable where the engine has Ractors, so
|
|
22
|
+
# the constant is readable from any Ractor.
|
|
23
|
+
DEFAULT_WRITE = ->(record, attributes) { record.update_columns(attributes) }
|
|
24
|
+
.then { |write| defined?(::Ractor) ? ::Ractor.make_shareable(write) : write }
|
|
25
|
+
|
|
26
|
+
# +write+ is called as `write.call(record, attributes)` inside the lock,
|
|
27
|
+
# e.g. to bump a lock version or run validations (`record.update!(attributes)`).
|
|
28
|
+
def initialize(translator:, write: DEFAULT_WRITE)
|
|
29
|
+
@translator = translator
|
|
30
|
+
@write = write
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(records, to:, style: :ui, instructions: nil, force: false, include_manual: false)
|
|
34
|
+
@locale = to.to_s
|
|
35
|
+
@force = force
|
|
36
|
+
@include_manual = include_manual
|
|
37
|
+
@result = { translated: 0, failed: {}, skipped: [] }
|
|
38
|
+
|
|
39
|
+
due, empty = records.select { |record| due?(record) }.partition { |record| record.translation_segments.any? }
|
|
40
|
+
@result[:skipped].concat(empty.map(&:id))
|
|
41
|
+
due.group_by(&:translation_source_locale).each do |from, group|
|
|
42
|
+
translate_group(group, from:, style:, instructions:)
|
|
43
|
+
end
|
|
44
|
+
@result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def due?(record) = record.translation_due?(@locale, force: @force, include_manual: @include_manual)
|
|
50
|
+
|
|
51
|
+
def translate_group(records, from:, style:, instructions:)
|
|
52
|
+
digests = records.to_h { |record| [record, record.translation_source_digest] }
|
|
53
|
+
owners = {}
|
|
54
|
+
segments = records.each_with_object({}) do |record, all|
|
|
55
|
+
record.translation_segments.each do |path, text|
|
|
56
|
+
key = "#{record.id}.#{path}"
|
|
57
|
+
raise Error, "Segment key #{key} is ambiguous across records (an id containing \".\"?)" if owners.key?(key)
|
|
58
|
+
|
|
59
|
+
owners[key] = [record, path]
|
|
60
|
+
all[key] = text
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result = @translator.translate(segments, from:, to: @locale, style:, instructions:)
|
|
65
|
+
records.each do |record|
|
|
66
|
+
translations = own(result.translations, owners, record).to_h
|
|
67
|
+
failed = own(result.failed.to_h { |key| [key, nil] }, owners, record).map(&:first)
|
|
68
|
+
@result[:failed][record.id] = failed if failed.any?
|
|
69
|
+
# All or nothing per record: one state entry covers the whole locale,
|
|
70
|
+
# so a partial write would read as current with values missing.
|
|
71
|
+
write(record, translations, digests[record]) if failed.empty? && translations.any?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The +record+'s pairs from +pairs+, keyed by its own segment path.
|
|
76
|
+
def own(pairs, owners, record)
|
|
77
|
+
pairs.filter_map do |key, value|
|
|
78
|
+
owner, path = owners[key]
|
|
79
|
+
[path, value] if owner.equal?(record)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def write(record, translations, digest)
|
|
84
|
+
record.with_lock do
|
|
85
|
+
next @result[:skipped] << record.id unless record.translation_source_digest == digest && due?(record)
|
|
86
|
+
|
|
87
|
+
manual = record.translation_manual?(@locale)
|
|
88
|
+
target = record.build_translation_target(translations)
|
|
89
|
+
@write.call(record, record.translation_attributes(@locale, target, manual:))
|
|
90
|
+
@result[:translated] += 1
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/locallingo/rubocop.rb
CHANGED
|
@@ -16,6 +16,6 @@ require_relative "../rubocop/cop/locallingo/strftime_in_view"
|
|
|
16
16
|
module Locallingo
|
|
17
17
|
# Absolute path to the cop defaults, for `inherit_gem: locallingo: <path>`.
|
|
18
18
|
module RuboCop
|
|
19
|
-
CONFIG_DEFAULT = File.expand_path("../../config/default.yml", __dir__)
|
|
19
|
+
CONFIG_DEFAULT = File.expand_path("../../config/default.yml", __dir__).freeze
|
|
20
20
|
end
|
|
21
21
|
end
|
data/lib/locallingo/settings.rb
CHANGED
|
@@ -12,10 +12,21 @@ module Locallingo
|
|
|
12
12
|
#
|
|
13
13
|
# Callables are resolved lazily on every use (never memoized), so keys can
|
|
14
14
|
# come from sources that aren't ready at configure time or that rotate.
|
|
15
|
+
#
|
|
16
|
+
# A String key is stored as a frozen plain String copy, so settings frozen by
|
|
17
|
+
# `Locallingo.configure` are Ractor-shareable; a callable is kept as is.
|
|
15
18
|
class Settings
|
|
16
19
|
PROVIDERS = %i[openai anthropic gemini deepseek openrouter].freeze
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
attr_reader(*PROVIDERS.map { |name| :"#{name}_api_key" })
|
|
22
|
+
|
|
23
|
+
PROVIDERS.each do |name|
|
|
24
|
+
define_method(:"#{name}_api_key=") do |value|
|
|
25
|
+
# A plain String copy: a subclass or its ivars could carry mutable state.
|
|
26
|
+
value = String.new(value).freeze if value.is_a?(String)
|
|
27
|
+
instance_variable_set(:"@#{name}_api_key", value)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
19
30
|
|
|
20
31
|
# The usable key for +provider+: callables are called, results stripped,
|
|
21
32
|
# and blank or unknown-provider values come back as nil.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
require_relative "configuration"
|
|
6
|
+
require_relative "prompt"
|
|
7
|
+
require_relative "providers/ruby_llm"
|
|
8
|
+
require_relative "batch_translator"
|
|
9
|
+
|
|
10
|
+
module Locallingo
|
|
11
|
+
# The public, file-agnostic entry point: translates a plain `{ key => text }`
|
|
12
|
+
# hash between two locales with the configured rules, glossary, language
|
|
13
|
+
# guide, provider and model.
|
|
14
|
+
#
|
|
15
|
+
# translator = Locallingo::Translator.new(
|
|
16
|
+
# config: Locallingo.configuration,
|
|
17
|
+
# context: RubyLLM.context { |c| c.anthropic_api_key = tenant_key } # optional
|
|
18
|
+
# )
|
|
19
|
+
# result = translator.translate({ "title" => "Hello" }, from: "en", to: "de", style: :prose)
|
|
20
|
+
# result.translations # => { "title" => "Hallo" }
|
|
21
|
+
# result.failed # => []
|
|
22
|
+
#
|
|
23
|
+
# Batching, retries, batch halving and omitted-key rounds come from
|
|
24
|
+
# BatchTranslator. Blank texts are not sent.
|
|
25
|
+
class Translator
|
|
26
|
+
Result = Data.define(:translations, :failed)
|
|
27
|
+
|
|
28
|
+
attr_reader :config
|
|
29
|
+
|
|
30
|
+
def initialize(config: Locallingo.configuration, context: nil, model: nil, logger: nil,
|
|
31
|
+
max_batch_chars: nil, pause: nil)
|
|
32
|
+
@config = config
|
|
33
|
+
@provider = Providers::RubyLLM.new(provider: config.provider, context:)
|
|
34
|
+
@model = model || config.translate_model
|
|
35
|
+
@logger = logger || Logger.new($stderr, level: Logger::WARN)
|
|
36
|
+
@max_batch_chars = max_batch_chars || config.max_batch_chars
|
|
37
|
+
@pause = pause || config.pause
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Raises MissingCredentialsError before any call when no key is available.
|
|
41
|
+
def translate(texts, from:, to:, style: :ui, instructions: nil)
|
|
42
|
+
@provider.ensure_credentials!
|
|
43
|
+
|
|
44
|
+
source = stringify_keys(texts).reject { |_key, text| text.to_s.strip.empty? }
|
|
45
|
+
return Result.new(translations: {}, failed: []) if source.empty?
|
|
46
|
+
|
|
47
|
+
prompt = Prompt.translation(config:, from:, to:, style:, instructions:)
|
|
48
|
+
translated, failed = batch_translator.call(source, source.keys, instructions: prompt)
|
|
49
|
+
Result.new(translations: translated.slice(*source.keys).except(*failed), failed:)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def stringify_keys(texts)
|
|
55
|
+
texts.each_with_object({}) do |(key, text), source|
|
|
56
|
+
raise Error, "Duplicate key #{key.to_s.inspect} (e.g. :#{key} and \"#{key}\")" if source.key?(key.to_s)
|
|
57
|
+
|
|
58
|
+
source[key.to_s] = text
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def batch_translator
|
|
63
|
+
@batch_translator ||= BatchTranslator.new(
|
|
64
|
+
provider: @provider, model: @model, batch_size: config.batch_size,
|
|
65
|
+
max_batch_chars: @max_batch_chars, pause: @pause,
|
|
66
|
+
log: ->(message, level: :info) { @logger.public_send(level, message) }
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|