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.
@@ -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 (en) hash. Returns :duplicate_value
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: "en",
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 "../state_store"
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 = locale_state[key]
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 "../state_store"
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
- stored = locale_state.dig(key, "source_hash")
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 locale_state.dig(key, "manual")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Locallingo
4
- VERSION = "0.4.3"
4
+ VERSION = "0.5.1"
5
5
  end
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.3
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.0'
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.0'
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