locallingo 0.5.0 → 0.5.1
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 +21 -0
- data/README.md +92 -0
- data/config/locallingo.default.yml +6 -0
- data/lib/locallingo/active_record.rb +148 -0
- data/lib/locallingo/batch_translator.rb +26 -3
- data/lib/locallingo/configuration.rb +2 -0
- data/lib/locallingo/content_leaves.rb +40 -0
- data/lib/locallingo/drift.rb +93 -0
- data/lib/locallingo/manager.rb +13 -57
- data/lib/locallingo/prompt.rb +73 -0
- data/lib/locallingo/providers/ruby_llm.rb +31 -9
- data/lib/locallingo/record_translator.rb +91 -0
- 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 +4 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b218e7422188db797e061b9cf1133b1b25a630e9e4c6237c04f0f7fa94dd2b4f
|
|
4
|
+
data.tar.gz: 742d0794ade8f32f9346d4a02b61554b7cb81321569c5297f9d93c3f1639f4e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48e0a2e2625a03fc0d78e9b5a9884efc4ec0ee7525533f150865d7bb391c473edd96f44bbca6974a24a6e564d4d08e644a0b36403a5c8a51b28a0943d54c1d1c
|
|
7
|
+
data.tar.gz: 4f08c9b89ab57351c5099ff6e31c4c24fa6b33a16df8ec0b70cb0ba365e292d90ef0fbe047d4b5494b9c2c7abe0627657c6ce4b545fd4a4552150f7338fd159d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
|
+
- `Locallingo::Translator#translate(texts, from:, to:, style:, instructions:)`
|
|
11
|
+
translates a plain `{ key => text }` hash with the configured prompt,
|
|
12
|
+
glossary, language guide, provider and model, and returns `translations` and
|
|
13
|
+
`failed`. (#33)
|
|
14
|
+
- `Locallingo::Prompt.translation`: the translation prompt, public. `style:
|
|
15
|
+
:prose` keeps Markdown structure and drops the UI brevity rule;
|
|
16
|
+
`instructions:` is appended after the language guide. The CLI's `:ui`
|
|
17
|
+
prompt is unchanged. (#33)
|
|
18
|
+
- `Providers::RubyLLM.new(provider:, context:)` and `Translator.new(context:)`
|
|
19
|
+
take a `RubyLLM::Context`; with one, the global `RubyLLM.config` is never
|
|
20
|
+
written. (#33)
|
|
21
|
+
- `translate.max_batch_chars` limits the source characters per batch (a single
|
|
22
|
+
value over the limit is sent alone, never split); `translate.pause` sets the
|
|
23
|
+
sleep after each batch (default `1.0`). (#33)
|
|
24
|
+
- `Locallingo::Drift`: missing / outdated / manual / hand-edited decisions as a
|
|
25
|
+
value over the state entry shape, for strings and nested values, with a
|
|
26
|
+
pluggable hasher. `Manager` and the validators use it; behaviour is
|
|
27
|
+
unchanged. (#33)
|
|
28
|
+
- `require "locallingo/active_record"` adds `Locallingo::Translatable`
|
|
29
|
+
(`translates_content`), and `Locallingo::RecordTranslator` translates many
|
|
30
|
+
records in one batched call, writing each under its row lock. (#33)
|
|
10
31
|
- `lingo quality --prefix yoga.` checks only the keys under a prefix, in both
|
|
11
32
|
the static checks and the AI pass. A prefix that matches nothing exits `1`. (#10, #29)
|
|
12
33
|
- `lingo quality --key a.b` (repeatable) checks exactly the named keys, and the
|
data/README.md
CHANGED
|
@@ -146,6 +146,98 @@ Locallingo::Manager.new(config:).validate # => [violations]
|
|
|
146
146
|
Locallingo::QualityChecker.new(config:).check(locale: "en")
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
### Translating text that is not in locale files
|
|
150
|
+
|
|
151
|
+
`Locallingo::Translator` runs the same prompt (rules, glossary, language guide),
|
|
152
|
+
provider, model and batching over a plain `{ key => text }` hash:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
require "ruby_llm" # for RubyLLM.context (Bundler.require already loads it in Rails)
|
|
156
|
+
|
|
157
|
+
translator = Locallingo::Translator.new(
|
|
158
|
+
config: Locallingo.configuration(root_path: Dir.pwd),
|
|
159
|
+
context: RubyLLM.context { |c| c.anthropic_api_key = tenant_key }, # optional, per-tenant key
|
|
160
|
+
pause: 0 # no sleep between batches
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
result = translator.translate(
|
|
164
|
+
{ "title" => "Hello", "intro" => "Some **markdown**." },
|
|
165
|
+
from: "en", to: "de",
|
|
166
|
+
style: :prose, # :ui (default) or :prose (keeps Markdown, no brevity rule)
|
|
167
|
+
instructions: "Write in a warm, plain voice." # appended after the language guide
|
|
168
|
+
)
|
|
169
|
+
result.translations # => { "title" => "Hallo", "intro" => "Etwas **Markdown**." }
|
|
170
|
+
result.failed # => keys that failed after retries
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- With a `context:`, every call uses that `RubyLLM::Context`'s config; the
|
|
174
|
+
global `RubyLLM.config` is never written, so tenants can translate
|
|
175
|
+
concurrently.
|
|
176
|
+
- `MissingCredentialsError` is raised before any call when no key is found.
|
|
177
|
+
- `translate.max_batch_chars` (or `max_batch_chars:`) limits the source
|
|
178
|
+
characters per batch (a single longer value is sent alone, not split); `translate.pause` (or `pause:`) is the sleep after each
|
|
179
|
+
batch. Retries of failed or omitted keys keep their own backoff.
|
|
180
|
+
- `Locallingo::Prompt.translation(config:, from:, to:, style:, instructions:)`
|
|
181
|
+
returns the prompt itself.
|
|
182
|
+
|
|
183
|
+
`Locallingo::Drift` is the missing / outdated / hand-edited rule over the state
|
|
184
|
+
entry shape (`source_hash`, `target_hash`, `manual`), for strings or nested
|
|
185
|
+
values:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
drift = Locallingo::Drift.new # hasher: any callable(String) -> String
|
|
189
|
+
drift.status(source:, target:, entry:) # => :missing | :outdated | :current
|
|
190
|
+
drift.due?(source:, target:, entry:, force: false, include_manual: false)
|
|
191
|
+
drift.hand_edited?(target:, entry:)
|
|
192
|
+
drift.record(source:, target:, manual: false) # => a new entry
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Translating records
|
|
196
|
+
|
|
197
|
+
`require "locallingo/active_record"` (ActiveRecord >= 7.1; nothing loads
|
|
198
|
+
ActiveRecord otherwise) adds a concern for a JSON(B) column keyed by locale
|
|
199
|
+
plus a JSON(B) state column:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
class Article < ApplicationRecord
|
|
203
|
+
include Locallingo::Translatable
|
|
204
|
+
|
|
205
|
+
translates_content :body, # { "en" => { … }, "de" => { … } }
|
|
206
|
+
state: :translation_state, # { "de" => { "source_hash", "target_hash", "manual" } }
|
|
207
|
+
source_locale: -> { "en" }, # String/Symbol, or a callable run on the record
|
|
208
|
+
target_locales: -> { %w[de sv] },
|
|
209
|
+
translatable: ->(path, value) { true } # optional filter on flattened String leaves
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
article.translation_status(:de) # => :missing | :outdated | :current
|
|
213
|
+
article.translation_manual?(:de)
|
|
214
|
+
article.translation_due?(:de, force: false)
|
|
215
|
+
article.record_translation(:de, manual: false)
|
|
216
|
+
|
|
217
|
+
Locallingo::RecordTranslator.new(translator:)
|
|
218
|
+
.call(Article.where(id: ids), to: "de", style: :prose)
|
|
219
|
+
# => { translated: 3, failed: { article_id => [paths] }, skipped: [ids] }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- Saving a record runs `mark_hand_edits`: a target locale a person changed is
|
|
223
|
+
flagged `manual` and never machine-overwritten (`include_manual: true`
|
|
224
|
+
overrides, keeping the flag). That includes a target locale saved for the
|
|
225
|
+
first time, e.g. a source copy used as a placeholder at `create!`: leave
|
|
226
|
+
untranslated locales out of the column instead. A cleared locale is not
|
|
227
|
+
flagged and is due again, unless it was already `manual`: the flag stays, so
|
|
228
|
+
retranslating it needs `include_manual: true`.
|
|
229
|
+
- `RecordTranslator` sends one batched translation per source locale across all
|
|
230
|
+
due records, then writes each record under `record.with_lock`. A record whose
|
|
231
|
+
source changed, or whose target became manual, while the model answered is
|
|
232
|
+
skipped (`skipped:`) and left for the next run, as is a due record with no
|
|
233
|
+
translatable text.
|
|
234
|
+
- Nested values keep their shape; leaves the `translatable:` filter rejects are
|
|
235
|
+
copied from the source. A record with any segment that failed after retries
|
|
236
|
+
is not written at all: its paths are reported in `failed:`, and it stays due
|
|
237
|
+
for the next run (your fallback to the source locale applies meanwhile).
|
|
238
|
+
- Writes use `update_columns`; pass `write: ->(record, attributes) { … }` to
|
|
239
|
+
bump a lock version or run validations.
|
|
240
|
+
|
|
149
241
|
## License
|
|
150
242
|
|
|
151
243
|
MIT © Mikael Henriksson
|
|
@@ -26,6 +26,12 @@ defaults:
|
|
|
26
26
|
translate:
|
|
27
27
|
model: gpt-5-mini
|
|
28
28
|
batch_size: 20
|
|
29
|
+
# Optional cap on the characters of source text per batch (a batch closes at
|
|
30
|
+
# batch_size keys or max_batch_chars, whichever comes first). Useful for long
|
|
31
|
+
# prose; a single oversized value is still sent alone. null = keys only.
|
|
32
|
+
max_batch_chars: null
|
|
33
|
+
# Seconds to sleep after each batch (rate-limit courtesy). 0 disables it.
|
|
34
|
+
pause: 1.0
|
|
29
35
|
|
|
30
36
|
quality:
|
|
31
37
|
model: gpt-4o-mini
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/core_ext/class/attribute"
|
|
5
|
+
|
|
6
|
+
require_relative "../locallingo"
|
|
7
|
+
require_relative "content_leaves"
|
|
8
|
+
|
|
9
|
+
module Locallingo
|
|
10
|
+
# Opt-in ActiveRecord concern (`require "locallingo/active_record"`) for the
|
|
11
|
+
# common shape: a JSON(B) column keyed by locale plus a JSON(B) state column
|
|
12
|
+
# holding one Drift entry per target locale. Supports ActiveRecord >= 7.1.
|
|
13
|
+
#
|
|
14
|
+
# class Article < ApplicationRecord
|
|
15
|
+
# include Locallingo::Translatable
|
|
16
|
+
#
|
|
17
|
+
# translates_content :body, # { "en" => { … }, "de" => { … } }
|
|
18
|
+
# state: :translation_state, # { "de" => { "source_hash", "target_hash", "manual" } }
|
|
19
|
+
# source_locale: -> { "en" }, # String/Symbol, or a callable run on the record
|
|
20
|
+
# target_locales: -> { %w[de sv] },
|
|
21
|
+
# translatable: ->(path, value) { true } # optional filter on flattened String leaves
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# A `before_save` callback (#mark_hand_edits) flags target locales a person
|
|
25
|
+
# changed as manual, so Locallingo::RecordTranslator never overwrites them.
|
|
26
|
+
module Translatable
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
Definition = Data.define(:column, :state, :source_locale, :target_locales, :translatable, :drift)
|
|
30
|
+
|
|
31
|
+
included do
|
|
32
|
+
class_attribute :locallingo_translatable, instance_writer: false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class_methods do
|
|
36
|
+
def translates_content(column, state:, source_locale:, target_locales:, translatable: nil, hasher: nil)
|
|
37
|
+
self.locallingo_translatable = Definition.new(
|
|
38
|
+
column: column.to_s, state: state.to_s, source_locale:, target_locales:,
|
|
39
|
+
translatable: translatable || ->(_path, _value) { true },
|
|
40
|
+
drift: hasher ? Drift.new(hasher:) : Drift.new
|
|
41
|
+
)
|
|
42
|
+
before_save :mark_hand_edits
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def translation_source_locale = resolve_translation_option(locallingo_translatable.source_locale).to_s
|
|
47
|
+
|
|
48
|
+
def translation_target_locales
|
|
49
|
+
Array(resolve_translation_option(locallingo_translatable.target_locales)).map(&:to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def translation_source = translation_content[translation_source_locale]
|
|
53
|
+
def translation_target(locale) = translation_content[locale.to_s]
|
|
54
|
+
def translation_entry(locale) = translation_states[locale.to_s]
|
|
55
|
+
def translation_source_digest = translation_drift.digest(translation_source)
|
|
56
|
+
|
|
57
|
+
def translation_status(locale)
|
|
58
|
+
translation_drift.status(source: translation_source, target: translation_target(locale),
|
|
59
|
+
entry: translation_entry(locale))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def translation_manual?(locale) = translation_drift.manual?(translation_entry(locale))
|
|
63
|
+
|
|
64
|
+
def translation_due?(locale, force: false, include_manual: false)
|
|
65
|
+
translation_drift.due?(source: translation_source, target: translation_target(locale),
|
|
66
|
+
entry: translation_entry(locale), force:, include_manual:)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Records the current source and target of +locale+ as its state entry
|
|
70
|
+
# (assigned, not saved).
|
|
71
|
+
def record_translation(locale, manual: false)
|
|
72
|
+
entry = translation_drift.record(source: translation_source, target: translation_target(locale), manual:)
|
|
73
|
+
self[locallingo_translatable.state] = translation_states.merge(locale.to_s => entry)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Flags every target locale a person changed as manual: a recorded target
|
|
77
|
+
# that no longer matches its target_hash, or an untracked locale written in
|
|
78
|
+
# this save. A cleared target is not flagged — it is missing and due again.
|
|
79
|
+
def mark_hand_edits
|
|
80
|
+
(translation_target_locales - [translation_source_locale]).each do |locale|
|
|
81
|
+
target = translation_target(locale)
|
|
82
|
+
next if translation_drift.blank?(target)
|
|
83
|
+
|
|
84
|
+
entry = translation_entry(locale)
|
|
85
|
+
next unless translation_drift.hand_edited?(target:, entry:) || (entry.nil? && translation_written?(locale))
|
|
86
|
+
|
|
87
|
+
# A recorded source_hash is kept: a hand edit does not make an
|
|
88
|
+
# outdated translation current.
|
|
89
|
+
manual = translation_drift.record(source: translation_source, target:, manual: true)
|
|
90
|
+
manual["source_hash"] = entry["source_hash"] if entry&.key?("source_hash")
|
|
91
|
+
self[locallingo_translatable.state] = translation_states.merge(locale => manual)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The source's translatable String leaves, flattened (`steps[1].text`,
|
|
96
|
+
# `table[0][1]`). Unlike KeyFlattener, strings in nested arrays count.
|
|
97
|
+
def translation_segments
|
|
98
|
+
translation_leaves.filter_map do |path, (_, value)|
|
|
99
|
+
next unless value.is_a?(String) && !value.strip.empty?
|
|
100
|
+
|
|
101
|
+
[path, value] if locallingo_translatable.translatable.call(path, value)
|
|
102
|
+
end.to_h
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The target for translated +segments+ (`path => text`): a deep copy of the
|
|
106
|
+
# source with each translated leaf set in place, so every other value and
|
|
107
|
+
# container (numbers, nested lists, empty ones) keeps its place.
|
|
108
|
+
def build_translation_target(segments)
|
|
109
|
+
target = JSON.parse(JSON.generate(translation_source || {}))
|
|
110
|
+
leaves = translation_leaves
|
|
111
|
+
segments.each do |path, text|
|
|
112
|
+
*parents, last = leaves.fetch(path).first
|
|
113
|
+
(parents.empty? ? target : target.dig(*parents))[last] = text
|
|
114
|
+
end
|
|
115
|
+
target
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Column attributes that write +target+ for +locale+ plus its state entry.
|
|
119
|
+
def translation_attributes(locale, target, manual: false)
|
|
120
|
+
locale = locale.to_s
|
|
121
|
+
entry = translation_drift.record(source: translation_source, target:, manual:)
|
|
122
|
+
{
|
|
123
|
+
locallingo_translatable.column => translation_content.merge(locale => target),
|
|
124
|
+
locallingo_translatable.state => translation_states.merge(locale => entry)
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def translation_drift = locallingo_translatable.drift
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def translation_leaves = ContentLeaves.call(translation_source || {}, label: "#{self.class.name}##{id}")
|
|
133
|
+
|
|
134
|
+
def translation_content = self[locallingo_translatable.column] || {}
|
|
135
|
+
def translation_states = self[locallingo_translatable.state] || {}
|
|
136
|
+
|
|
137
|
+
def translation_written?(locale)
|
|
138
|
+
column = locallingo_translatable.column
|
|
139
|
+
return false unless will_save_change_to_attribute?(column)
|
|
140
|
+
|
|
141
|
+
(attribute_in_database(column) || {})[locale] != translation_target(locale)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def resolve_translation_option(option)
|
|
145
|
+
option.respond_to?(:call) ? instance_exec(&option) : option
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -17,10 +17,16 @@ module Locallingo
|
|
|
17
17
|
BASE_SLEEP_DURATION = 1.0
|
|
18
18
|
|
|
19
19
|
# `log` is called as `log.call(message, level: :info)`.
|
|
20
|
-
|
|
20
|
+
#
|
|
21
|
+
# A batch closes at `batch_size` keys or `max_batch_chars` characters of
|
|
22
|
+
# source text, whichever comes first; a single oversized value goes alone.
|
|
23
|
+
# `pause` is the sleep after each batch (0 in a background job).
|
|
24
|
+
def initialize(provider:, model:, batch_size:, log:, max_batch_chars: nil, pause: BASE_SLEEP_DURATION)
|
|
21
25
|
@provider = provider
|
|
22
26
|
@model = model
|
|
23
27
|
@batch_size = batch_size
|
|
28
|
+
@max_batch_chars = max_batch_chars
|
|
29
|
+
@pause = pause
|
|
24
30
|
@log = log
|
|
25
31
|
end
|
|
26
32
|
|
|
@@ -66,7 +72,7 @@ module Locallingo
|
|
|
66
72
|
translations = {}
|
|
67
73
|
failed = []
|
|
68
74
|
|
|
69
|
-
keys.
|
|
75
|
+
batches(keys).each do |batch|
|
|
70
76
|
result = translate_batch(batch.to_h { |key| [key, @source[key]] })
|
|
71
77
|
batch.each do |key|
|
|
72
78
|
if result.key?(key) && !result[key].to_s.empty?
|
|
@@ -75,12 +81,29 @@ module Locallingo
|
|
|
75
81
|
failed << key
|
|
76
82
|
end
|
|
77
83
|
end
|
|
78
|
-
sleep(
|
|
84
|
+
sleep(@pause) if @pause.positive?
|
|
79
85
|
end
|
|
80
86
|
|
|
81
87
|
[translations, failed]
|
|
82
88
|
end
|
|
83
89
|
|
|
90
|
+
def batches(keys)
|
|
91
|
+
return keys.each_slice(@batch_size).to_a unless @max_batch_chars
|
|
92
|
+
|
|
93
|
+
keys.each_with_object([[]]) do |key, groups|
|
|
94
|
+
groups << [] if full?(groups.last, key)
|
|
95
|
+
groups.last << key
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def full?(batch, key)
|
|
100
|
+
return false if batch.empty?
|
|
101
|
+
|
|
102
|
+
batch.size >= @batch_size || chars(batch) + chars([key]) > @max_batch_chars
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def chars(batch) = batch.sum { |key| @source[key].to_s.length }
|
|
106
|
+
|
|
84
107
|
def translate_batch(payload)
|
|
85
108
|
return {} if payload.empty?
|
|
86
109
|
|
|
@@ -54,6 +54,8 @@ module Locallingo
|
|
|
54
54
|
def provider = data.fetch("provider").to_sym
|
|
55
55
|
def translate_model = dig("translate", "model")
|
|
56
56
|
def batch_size = dig("translate", "batch_size")
|
|
57
|
+
def max_batch_chars = dig("translate", "max_batch_chars")
|
|
58
|
+
def pause = dig("translate", "pause") || 1.0
|
|
57
59
|
def quality_model = dig("quality", "model")
|
|
58
60
|
def quality_sample_size = dig("quality", "sample_size")
|
|
59
61
|
def british_spellings? = !!dig("quality", "british_spellings")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
# Walks a nested Hash/Array value to its leaves, keyed by a KeyFlattener-style
|
|
5
|
+
# path (`steps[1].text`, `table[0][1]`). Unlike KeyFlattener, strings inside
|
|
6
|
+
# nested arrays count, and each leaf carries the Hash keys and Array indices
|
|
7
|
+
# leading to it, so a value can be set back in place without parsing paths.
|
|
8
|
+
module ContentLeaves
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# `path => [parts, value]`. Two leaves with the same path (a literal "a.b"
|
|
12
|
+
# key next to a → b) raise: one would be silently dropped from translation.
|
|
13
|
+
def call(value, label: "content")
|
|
14
|
+
collect(value, [], {}, label)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def collect(value, parts, leaves, label)
|
|
18
|
+
case value
|
|
19
|
+
when Hash then value.each { |key, item| collect(item, parts + [key.to_s], leaves, label) }
|
|
20
|
+
when Array then value.each_with_index { |item, index| collect(item, parts + [index], leaves, label) }
|
|
21
|
+
else
|
|
22
|
+
path = path(parts)
|
|
23
|
+
raise Error, "Ambiguous translation path #{path.inspect} in #{label}" if leaves.key?(path)
|
|
24
|
+
|
|
25
|
+
leaves[path] = [parts, value]
|
|
26
|
+
end
|
|
27
|
+
leaves
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def path(parts)
|
|
31
|
+
parts.each_with_index.map do |part, index|
|
|
32
|
+
next "[#{part}]" if part.is_a?(Integer)
|
|
33
|
+
|
|
34
|
+
index.zero? ? part : ".#{part}"
|
|
35
|
+
end.join
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private_class_method :collect, :path
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -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
|
|
@@ -179,7 +178,8 @@ module Locallingo
|
|
|
179
178
|
|
|
180
179
|
def batch_translator
|
|
181
180
|
@batch_translator ||= BatchTranslator.new(
|
|
182
|
-
provider: @provider, model: config.translate_model, batch_size: config.batch_size,
|
|
181
|
+
provider: @provider, model: config.translate_model, batch_size: config.batch_size,
|
|
182
|
+
max_batch_chars: config.max_batch_chars, pause: config.pause, log: method(:log)
|
|
183
183
|
)
|
|
184
184
|
end
|
|
185
185
|
|
|
@@ -208,10 +208,7 @@ module Locallingo
|
|
|
208
208
|
return target.keys.select { |key| source.key?(key) } if all
|
|
209
209
|
|
|
210
210
|
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])
|
|
211
|
+
source.key?(key) && @drift.hand_edited?(target: target[key], entry: locale_state[key])
|
|
215
212
|
end
|
|
216
213
|
end
|
|
217
214
|
|
|
@@ -261,47 +258,10 @@ module Locallingo
|
|
|
261
258
|
failed
|
|
262
259
|
end
|
|
263
260
|
|
|
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
|
|
261
|
+
def translation_prompt(locale) = Prompt.translation(config:, from: config.source_locale, to: locale)
|
|
302
262
|
|
|
303
263
|
def determine_keys_to_translate(source, target, locale_state, force:, force_keys:, exceptions:)
|
|
304
|
-
manual_keys = locale_state.select { |
|
|
264
|
+
manual_keys = locale_state.select { |_key, entry| @drift.manual?(entry) }.keys.to_set
|
|
305
265
|
return source.keys - manual_keys.to_a if force
|
|
306
266
|
return force_keys & source.keys if force_keys.any?
|
|
307
267
|
|
|
@@ -314,7 +274,7 @@ module Locallingo
|
|
|
314
274
|
# Drops manual keys from the named force_keys (with a loud warning) unless
|
|
315
275
|
# the caller opted in to overwriting them.
|
|
316
276
|
def resolve_force_keys(source, force_keys, locale_state, include_manual:, locale:)
|
|
317
|
-
manual = (force_keys & source.keys).select { |key|
|
|
277
|
+
manual = (force_keys & source.keys).select { |key| @drift.manual?(locale_state[key]) }
|
|
318
278
|
return force_keys if manual.empty?
|
|
319
279
|
|
|
320
280
|
if include_manual
|
|
@@ -335,15 +295,11 @@ module Locallingo
|
|
|
335
295
|
translations.each_key do |key|
|
|
336
296
|
next unless source[key]
|
|
337
297
|
|
|
338
|
-
existing = locale_state[key]
|
|
339
|
-
entry = {
|
|
340
|
-
"source_hash" => @state.hash(source[key]),
|
|
341
|
-
"target_hash" => @state.hash(translations[key])
|
|
342
|
-
}
|
|
343
298
|
# A force-keyed retranslation overwrites a manual value only with
|
|
344
299
|
# include_manual, and even then the protection flag must survive.
|
|
345
|
-
|
|
346
|
-
|
|
300
|
+
locale_state[key] = @drift.record(
|
|
301
|
+
source: source[key], target: translations[key], manual: @drift.manual?(locale_state[key])
|
|
302
|
+
)
|
|
347
303
|
end
|
|
348
304
|
end
|
|
349
305
|
|
|
@@ -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,11 @@ 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
|
+
# Given a `RubyLLM::Context` (`RubyLLM.context { |c| c.anthropic_api_key = … }`),
|
|
23
|
+
# every chat runs on that context's own configuration: the global
|
|
24
|
+
# `RubyLLM.config` is never written, so translators with different keys
|
|
25
|
+
# (one per tenant) can run concurrently.
|
|
21
26
|
class RubyLLM
|
|
22
27
|
# Maps a RubyLLM provider symbol to the ENV var whose presence indicates
|
|
23
28
|
# credentials are available, so we can fail fast with a clear message
|
|
@@ -30,10 +35,11 @@ module Locallingo
|
|
|
30
35
|
openrouter: "OPENROUTER_API_KEY"
|
|
31
36
|
}.freeze
|
|
32
37
|
|
|
33
|
-
attr_reader :provider
|
|
38
|
+
attr_reader :provider, :context
|
|
34
39
|
|
|
35
|
-
def initialize(provider:)
|
|
40
|
+
def initialize(provider:, context: nil)
|
|
36
41
|
@provider = provider.to_sym
|
|
42
|
+
@context = context
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# True when a key for the configured provider is found in any source
|
|
@@ -50,6 +56,12 @@ module Locallingo
|
|
|
50
56
|
def ensure_credentials!
|
|
51
57
|
return if credentials?
|
|
52
58
|
|
|
59
|
+
if context
|
|
60
|
+
raise MissingCredentialsError,
|
|
61
|
+
"No credentials for provider #{provider.inspect}: the given RubyLLM context " \
|
|
62
|
+
"has no #{key_setting}."
|
|
63
|
+
end
|
|
64
|
+
|
|
53
65
|
raise MissingCredentialsError,
|
|
54
66
|
"No credentials for provider #{provider.inspect}. " \
|
|
55
67
|
"Set #{CREDENTIAL_ENV[provider]} in ENV, call " \
|
|
@@ -61,20 +73,21 @@ module Locallingo
|
|
|
61
73
|
# the model and return the parsed JSON object as a Hash.
|
|
62
74
|
def chat(model:, instructions:, payload:)
|
|
63
75
|
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
76
|
|
|
77
|
+
conversation = new_chat(model).with_instructions(instructions, cache_until_here: true)
|
|
72
78
|
response = conversation.ask(JSON.pretty_generate(payload))
|
|
73
79
|
JsonExtraction.extract_object(response.content)
|
|
74
80
|
end
|
|
75
81
|
|
|
76
82
|
private
|
|
77
83
|
|
|
84
|
+
def new_chat(model)
|
|
85
|
+
return context.chat(model:, provider:, assume_model_exists: true) if context
|
|
86
|
+
|
|
87
|
+
configure_credentials!
|
|
88
|
+
::RubyLLM.chat(model:, provider:, assume_model_exists: true)
|
|
89
|
+
end
|
|
90
|
+
|
|
78
91
|
# RubyLLM does not read provider API keys from ENV on its own, so a
|
|
79
92
|
# standalone CLI run (no Rails initializer to call RubyLLM.configure)
|
|
80
93
|
# would raise "Missing configuration for <provider>". Push the resolved
|
|
@@ -98,9 +111,18 @@ module Locallingo
|
|
|
98
111
|
# Key resolution across sources, in precedence order. Used by
|
|
99
112
|
# #credentials? as a fail-fast check before any network call.
|
|
100
113
|
def resolved_api_key
|
|
114
|
+
return context_api_key if context
|
|
115
|
+
|
|
101
116
|
settings_api_key || host_configured_api_key || env_api_key
|
|
102
117
|
end
|
|
103
118
|
|
|
119
|
+
# With a context, its own config is the only source: the chat never
|
|
120
|
+
# reads the settings, the global config or ENV.
|
|
121
|
+
def context_api_key
|
|
122
|
+
config = context.config
|
|
123
|
+
config.respond_to?(key_setting) ? presence(config.public_send(key_setting)) : nil
|
|
124
|
+
end
|
|
125
|
+
|
|
104
126
|
def settings_api_key
|
|
105
127
|
Locallingo.settings.api_key_for(provider)
|
|
106
128
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
DEFAULT_WRITE = ->(record, attributes) { record.update_columns(attributes) }
|
|
22
|
+
|
|
23
|
+
# +write+ is called as `write.call(record, attributes)` inside the lock,
|
|
24
|
+
# e.g. to bump a lock version or run validations (`record.update!(attributes)`).
|
|
25
|
+
def initialize(translator:, write: DEFAULT_WRITE)
|
|
26
|
+
@translator = translator
|
|
27
|
+
@write = write
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(records, to:, style: :ui, instructions: nil, force: false, include_manual: false)
|
|
31
|
+
@locale = to.to_s
|
|
32
|
+
@force = force
|
|
33
|
+
@include_manual = include_manual
|
|
34
|
+
@result = { translated: 0, failed: {}, skipped: [] }
|
|
35
|
+
|
|
36
|
+
due, empty = records.select { |record| due?(record) }.partition { |record| record.translation_segments.any? }
|
|
37
|
+
@result[:skipped].concat(empty.map(&:id))
|
|
38
|
+
due.group_by(&:translation_source_locale).each do |from, group|
|
|
39
|
+
translate_group(group, from:, style:, instructions:)
|
|
40
|
+
end
|
|
41
|
+
@result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def due?(record) = record.translation_due?(@locale, force: @force, include_manual: @include_manual)
|
|
47
|
+
|
|
48
|
+
def translate_group(records, from:, style:, instructions:)
|
|
49
|
+
digests = records.to_h { |record| [record, record.translation_source_digest] }
|
|
50
|
+
owners = {}
|
|
51
|
+
segments = records.each_with_object({}) do |record, all|
|
|
52
|
+
record.translation_segments.each do |path, text|
|
|
53
|
+
key = "#{record.id}.#{path}"
|
|
54
|
+
raise Error, "Segment key #{key} is ambiguous across records (an id containing \".\"?)" if owners.key?(key)
|
|
55
|
+
|
|
56
|
+
owners[key] = [record, path]
|
|
57
|
+
all[key] = text
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result = @translator.translate(segments, from:, to: @locale, style:, instructions:)
|
|
62
|
+
records.each do |record|
|
|
63
|
+
translations = own(result.translations, owners, record).to_h
|
|
64
|
+
failed = own(result.failed.to_h { |key| [key, nil] }, owners, record).map(&:first)
|
|
65
|
+
@result[:failed][record.id] = failed if failed.any?
|
|
66
|
+
# All or nothing per record: one state entry covers the whole locale,
|
|
67
|
+
# so a partial write would read as current with values missing.
|
|
68
|
+
write(record, translations, digests[record]) if failed.empty? && translations.any?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The +record+'s pairs from +pairs+, keyed by its own segment path.
|
|
73
|
+
def own(pairs, owners, record)
|
|
74
|
+
pairs.filter_map do |key, value|
|
|
75
|
+
owner, path = owners[key]
|
|
76
|
+
[path, value] if owner.equal?(record)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write(record, translations, digest)
|
|
81
|
+
record.with_lock do
|
|
82
|
+
next @result[:skipped] << record.id unless record.translation_source_digest == digest && due?(record)
|
|
83
|
+
|
|
84
|
+
manual = record.translation_manual?(@locale)
|
|
85
|
+
target = record.build_translation_target(translations)
|
|
86
|
+
@write.call(record, record.translation_attributes(@locale, target, manual:))
|
|
87
|
+
@result[:translated] += 1
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../
|
|
3
|
+
require_relative "../drift"
|
|
4
4
|
|
|
5
5
|
module Locallingo
|
|
6
6
|
module Validators
|
|
@@ -12,19 +12,15 @@ module Locallingo
|
|
|
12
12
|
# it was edited by a human — surface it so the operator can protect it with
|
|
13
13
|
# `accept-edits` before the next translate run overwrites it.
|
|
14
14
|
class ManualEdits
|
|
15
|
-
def initialize(cli_name: "lingo")
|
|
15
|
+
def initialize(cli_name: "lingo", drift: Drift.new)
|
|
16
16
|
@cli_name = cli_name
|
|
17
|
+
@drift = drift
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# +target+ is the flat target hash; +locale_state+ its loaded state.
|
|
20
21
|
def call(target:, locale_state:, locale:)
|
|
21
22
|
target.filter_map do |key, value|
|
|
22
|
-
entry
|
|
23
|
-
next unless entry.is_a?(Hash)
|
|
24
|
-
next if entry["manual"]
|
|
25
|
-
|
|
26
|
-
stored = entry["target_hash"]
|
|
27
|
-
next unless stored && stored != StateStore.hash(value)
|
|
23
|
+
next unless @drift.hand_edited?(target: value, entry: locale_state[key])
|
|
28
24
|
|
|
29
25
|
{
|
|
30
26
|
type: :manual_edit,
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../
|
|
3
|
+
require_relative "../drift"
|
|
4
4
|
|
|
5
5
|
module Locallingo
|
|
6
6
|
module Validators
|
|
7
7
|
# Reports target keys whose recorded source hash no longer matches the
|
|
8
8
|
# current source value — i.e. the English text changed after translation.
|
|
9
9
|
class Outdated
|
|
10
|
-
def initialize(cli_name: "lingo")
|
|
10
|
+
def initialize(cli_name: "lingo", drift: Drift.new)
|
|
11
11
|
@cli_name = cli_name
|
|
12
|
+
@drift = drift
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
# +source+ is the flat source hash; +locale_state+ is the target locale's
|
|
@@ -27,8 +28,7 @@ module Locallingo
|
|
|
27
28
|
# Keys whose stored source_hash differs from the current source hash.
|
|
28
29
|
def outdated_keys(source, locale_state)
|
|
29
30
|
source.filter_map do |key, value|
|
|
30
|
-
|
|
31
|
-
key if stored && stored != StateStore.hash(value)
|
|
31
|
+
key if @drift.outdated?(source: value, entry: locale_state[key])
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -37,7 +37,7 @@ module Locallingo
|
|
|
37
37
|
# Manual keys must not be pushed toward machine retranslation — the value
|
|
38
38
|
# is hand-curated, so the human updates it and re-accepts.
|
|
39
39
|
def suggestion_for(key, locale, locale_state)
|
|
40
|
-
if
|
|
40
|
+
if @drift.manual?(locale_state[key])
|
|
41
41
|
"Source changed for a manually-curated key. Update the #{locale} value by hand, " \
|
|
42
42
|
"then run: #{@cli_name} accept-edits --locale #{locale} --key #{key}"
|
|
43
43
|
else
|
data/lib/locallingo/version.rb
CHANGED
data/lib/locallingo.rb
CHANGED
|
@@ -6,8 +6,12 @@ require_relative "locallingo/configuration"
|
|
|
6
6
|
require_relative "locallingo/json_extraction"
|
|
7
7
|
require_relative "locallingo/key_flattener"
|
|
8
8
|
require_relative "locallingo/state_store"
|
|
9
|
+
require_relative "locallingo/drift"
|
|
9
10
|
require_relative "locallingo/providers/ruby_llm"
|
|
10
11
|
require_relative "locallingo/batch_translator"
|
|
12
|
+
require_relative "locallingo/prompt"
|
|
13
|
+
require_relative "locallingo/translator"
|
|
14
|
+
require_relative "locallingo/record_translator"
|
|
11
15
|
require_relative "locallingo/validators/missing"
|
|
12
16
|
require_relative "locallingo/validators/outdated"
|
|
13
17
|
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.5.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -69,21 +69,27 @@ files:
|
|
|
69
69
|
- config/locallingo.default.yml
|
|
70
70
|
- exe/lingo
|
|
71
71
|
- lib/locallingo.rb
|
|
72
|
+
- lib/locallingo/active_record.rb
|
|
72
73
|
- lib/locallingo/batch_translator.rb
|
|
73
74
|
- lib/locallingo/cli.rb
|
|
74
75
|
- lib/locallingo/configuration.rb
|
|
76
|
+
- lib/locallingo/content_leaves.rb
|
|
77
|
+
- lib/locallingo/drift.rb
|
|
75
78
|
- lib/locallingo/json_extraction.rb
|
|
76
79
|
- lib/locallingo/key_flattener.rb
|
|
77
80
|
- lib/locallingo/manager.rb
|
|
81
|
+
- lib/locallingo/prompt.rb
|
|
78
82
|
- lib/locallingo/providers/ruby_llm.rb
|
|
79
83
|
- lib/locallingo/quality/british_spellings.rb
|
|
80
84
|
- lib/locallingo/quality/static_rules.rb
|
|
81
85
|
- lib/locallingo/quality/terminology.rb
|
|
82
86
|
- lib/locallingo/quality_checker.rb
|
|
87
|
+
- lib/locallingo/record_translator.rb
|
|
83
88
|
- lib/locallingo/reporter.rb
|
|
84
89
|
- lib/locallingo/rubocop.rb
|
|
85
90
|
- lib/locallingo/settings.rb
|
|
86
91
|
- lib/locallingo/state_store.rb
|
|
92
|
+
- lib/locallingo/translator.rb
|
|
87
93
|
- lib/locallingo/validators/duplicate_values.rb
|
|
88
94
|
- lib/locallingo/validators/manual_edits.rb
|
|
89
95
|
- lib/locallingo/validators/missing.rb
|