locallingo 0.4.3 → 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 +74 -3
- data/README.md +94 -0
- data/config/locallingo.default.yml +11 -1
- data/lib/locallingo/active_record.rb +148 -0
- data/lib/locallingo/batch_translator.rb +151 -0
- data/lib/locallingo/cli.rb +44 -10
- data/lib/locallingo/configuration.rb +4 -1
- data/lib/locallingo/content_leaves.rb +40 -0
- data/lib/locallingo/drift.rb +93 -0
- data/lib/locallingo/json_extraction.rb +14 -2
- data/lib/locallingo/manager.rb +68 -121
- data/lib/locallingo/prompt.rb +73 -0
- data/lib/locallingo/providers/ruby_llm.rb +36 -9
- data/lib/locallingo/quality_checker.rb +31 -6
- data/lib/locallingo/record_translator.rb +91 -0
- data/lib/locallingo/translator.rb +70 -0
- data/lib/locallingo/validators/duplicate_values.rb +4 -4
- 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 +5 -0
- metadata +10 -3
|
@@ -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
|
|
@@ -12,9 +12,9 @@ module Locallingo
|
|
|
12
12
|
AR_ATTRIBUTES_PREFIX = "activerecord.attributes."
|
|
13
13
|
AR_PREFIX = "activerecord."
|
|
14
14
|
|
|
15
|
-
# +source+ is the flat source
|
|
16
|
-
# violations naming both keys.
|
|
17
|
-
def call(source:)
|
|
15
|
+
# +source+ is the flat source-locale hash; +locale+ is the configured source
|
|
16
|
+
# locale. Returns :duplicate_value violations naming both keys.
|
|
17
|
+
def call(source:, locale:)
|
|
18
18
|
ar_keys_by_value = source
|
|
19
19
|
.select { |key, _| key.start_with?(AR_ATTRIBUTES_PREFIX) }
|
|
20
20
|
.group_by { |_key, value| value }
|
|
@@ -30,7 +30,7 @@ module Locallingo
|
|
|
30
30
|
ar_dupes = ar_keys_by_value[value]
|
|
31
31
|
{
|
|
32
32
|
type: :duplicate_value,
|
|
33
|
-
locale
|
|
33
|
+
locale:,
|
|
34
34
|
key:,
|
|
35
35
|
suggestion: "Value '#{value}' duplicates #{ar_dupes.join(", ")}. " \
|
|
36
36
|
"Use #{ar_dupes.first} instead."
|
|
@@ -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,7 +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"
|
|
11
|
+
require_relative "locallingo/batch_translator"
|
|
12
|
+
require_relative "locallingo/prompt"
|
|
13
|
+
require_relative "locallingo/translator"
|
|
14
|
+
require_relative "locallingo/record_translator"
|
|
10
15
|
require_relative "locallingo/validators/missing"
|
|
11
16
|
require_relative "locallingo/validators/outdated"
|
|
12
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.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -15,7 +15,7 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '1
|
|
18
|
+
version: '2.1'
|
|
19
19
|
- - "<"
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: '3'
|
|
@@ -25,7 +25,7 @@ dependencies:
|
|
|
25
25
|
requirements:
|
|
26
26
|
- - ">="
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
|
-
version: '1
|
|
28
|
+
version: '2.1'
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '3'
|
|
@@ -69,20 +69,27 @@ files:
|
|
|
69
69
|
- config/locallingo.default.yml
|
|
70
70
|
- exe/lingo
|
|
71
71
|
- lib/locallingo.rb
|
|
72
|
+
- lib/locallingo/active_record.rb
|
|
73
|
+
- lib/locallingo/batch_translator.rb
|
|
72
74
|
- lib/locallingo/cli.rb
|
|
73
75
|
- lib/locallingo/configuration.rb
|
|
76
|
+
- lib/locallingo/content_leaves.rb
|
|
77
|
+
- lib/locallingo/drift.rb
|
|
74
78
|
- lib/locallingo/json_extraction.rb
|
|
75
79
|
- lib/locallingo/key_flattener.rb
|
|
76
80
|
- lib/locallingo/manager.rb
|
|
81
|
+
- lib/locallingo/prompt.rb
|
|
77
82
|
- lib/locallingo/providers/ruby_llm.rb
|
|
78
83
|
- lib/locallingo/quality/british_spellings.rb
|
|
79
84
|
- lib/locallingo/quality/static_rules.rb
|
|
80
85
|
- lib/locallingo/quality/terminology.rb
|
|
81
86
|
- lib/locallingo/quality_checker.rb
|
|
87
|
+
- lib/locallingo/record_translator.rb
|
|
82
88
|
- lib/locallingo/reporter.rb
|
|
83
89
|
- lib/locallingo/rubocop.rb
|
|
84
90
|
- lib/locallingo/settings.rb
|
|
85
91
|
- lib/locallingo/state_store.rb
|
|
92
|
+
- lib/locallingo/translator.rb
|
|
86
93
|
- lib/locallingo/validators/duplicate_values.rb
|
|
87
94
|
- lib/locallingo/validators/manual_edits.rb
|
|
88
95
|
- lib/locallingo/validators/missing.rb
|