purizumu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 41586286c24fbf46df721821901d6a0ef19c30cc9b28f4e186202140dd84f8da
4
+ data.tar.gz: 9c1e474e116fa9f63c817f64f4e00727ee59e69d4c70577292e8a0446f194934
5
+ SHA512:
6
+ metadata.gz: 92e7c2d4c42e313b0381ab3f6e327f897dfac150d41644d1e60ffc2d7b5a99948dd5c5679d61b0a03a4d4b79d2e8eb3490104af6a4930ab58e8a700d99699695
7
+ data.tar.gz: b7d7b134a4c85de69272411df15a21674188a9cedb3d811b9b58923e37d91774d0e7f27c0e97ff95a0c281a183e134631f380a44807381d0846ed165263f1e3c
data/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # Purizumu
2
+
3
+ Purizumu auto-translates Rails model attributes and stores the translated content in your database. Translations are generated lazily the first time an attribute is read for a locale that does not already exist in the translation table.
4
+
5
+ For example, if `Gem#human_name` is marked as translatable and a request reads that attribute while `I18n.locale` is `:jp`, Purizumu checks its translation table first. If no Japanese translation exists yet, it asks the configured translation engine to generate one, stores the result, and returns it.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ gem install purizumu
11
+ ```
12
+
13
+ Or add it to your Rails application's Gemfile:
14
+
15
+ ```ruby
16
+ gem "purizumu"
17
+ ```
18
+
19
+ Then install dependencies:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ Generate the migration and migrate:
26
+
27
+ ```bash
28
+ rails generate purizumu:install
29
+ rails db:migrate
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ Create an initializer such as `config/initializers/purizumu.rb`:
35
+
36
+ ```ruby
37
+ Purizumu.configure do |config|
38
+ config.engine = :xai
39
+ config.key = ENV["XAI_TRANSLATE_KEY"]
40
+ config.model = "grok-4.20-reasoning"
41
+ end
42
+ ```
43
+
44
+ Available configuration options:
45
+
46
+ - `config.engine`: translation engine symbol. Purizumu currently ships with `:xai`.
47
+ - `config.key`: API key for the configured engine.
48
+ - `config.model`: LLM model name.
49
+ - `config.base_url`: API base URL. Defaults to `https://api.x.ai/v1`.
50
+ - `config.source_locale`: source locale used for untranslated records. Defaults to `I18n.default_locale`.
51
+ - `config.transport`: optional callable used for HTTP requests. Mainly useful for testing or custom networking.
52
+
53
+ ## Usage
54
+
55
+ Mark model attributes as translatable:
56
+
57
+ ```ruby
58
+ class Gem < ApplicationRecord
59
+ attribute_translation :human_name
60
+ end
61
+ ```
62
+
63
+ When the attribute is read, Purizumu checks the translations table before falling back to generation:
64
+
65
+ ```ruby
66
+ class GemsController < ApplicationController
67
+ def index
68
+ gem = Gem.find(48_127)
69
+
70
+ I18n.locale = :en
71
+ puts gem.human_name
72
+
73
+ I18n.locale = :jp
74
+ puts gem.human_name
75
+
76
+ I18n.locale = :ru
77
+ puts gem.human_name
78
+ end
79
+ end
80
+ ```
81
+
82
+ Example output:
83
+
84
+ ```text
85
+ Prism
86
+ プリズム
87
+ Призма
88
+ ```
89
+
90
+ ## How It Works
91
+
92
+ Purizumu stores translations in `purizumu_translations`. Each translation row is keyed by:
93
+
94
+ - `model_class_name`
95
+ - `record_id`
96
+ - `attribute_name`
97
+ - `locale`
98
+
99
+ The table also stores the translated `content`.
100
+
101
+ This means a single model record can have a different translation for each tracked attribute and locale.
102
+
103
+ Example rows:
104
+
105
+ | model_class_name | record_id | attribute_name | locale | content |
106
+ | ---------- | --------- | -------------- | ------ | ------- |
107
+ | Gem | 48127 | human_name | en | Prism |
108
+ | Gem | 48127 | human_name | es | Prisma |
109
+ | Gem | 48127 | human_name | jp | プリズム |
110
+ | Gem | 48127 | human_name | ru | Призма |
111
+ | Gem | 73491 | human_name | en | Different Gem |
112
+ | Gem | 73491 | human_name | es | Gema Diferente |
113
+ | Gem | 73491 | human_name | jp | 異なる宝石 |
114
+ | Gem | 73491 | human_name | ru | Другой самоцвет |
115
+
116
+ Purizumu intentionally uses Rails-safe key names in the translation table:
117
+
118
+ - `record_id` is used instead of a plain `id` column because the table needs its own primary key.
119
+ - `model_class_name` is used instead of `model_name` because `model_name` is already defined by Active Record internals.
120
+
121
+ ## xAI / Grok Engine
122
+
123
+ The bundled xAI engine uses the chat completions API and enforces structured output with a function tool. It does not rely on "please return JSON" prompting alone. Purizumu sends a single required function definition and forces the model to answer through that tool call.
124
+
125
+ The expected tool payload is:
126
+
127
+ ```json
128
+ {
129
+ "translated_text": "..."
130
+ }
131
+ ```
132
+
133
+ ## Generator Output
134
+
135
+ `rails generate purizumu:install` creates a migration for the translation table with:
136
+
137
+ - `model_name`
138
+ - `model_class_name`
139
+ - `record_id`
140
+ - `attribute_name`
141
+ - `locale`
142
+ - `content`
143
+
144
+ It also adds a unique composite index across `model_class_name`, `record_id`, `attribute_name`, and `locale`.
145
+
146
+ ## Development
147
+
148
+ Run the test suite:
149
+
150
+ ```bash
151
+ bundle exec rspec
152
+ ```
153
+
154
+ Run Rubocop:
155
+
156
+ ```bash
157
+ bundle exec rubocop
158
+ ```
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Purizumu
7
+ module Generators
8
+ # Generates the migration that creates Purizumu's translation cache table.
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path('templates', __dir__)
13
+
14
+ def self.next_migration_number(dirname)
15
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
16
+ end
17
+
18
+ def create_migration_file
19
+ migration_template(
20
+ 'create_purizumu_translations.rb.erb',
21
+ 'db/migrate/create_purizumu_translations.rb'
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ class CreatePurizumuTranslations < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :purizumu_translations do |t|
4
+ t.string :model_class_name, null: false
5
+ t.bigint :record_id, null: false
6
+ t.string :attribute_name, null: false
7
+ t.string :locale, null: false
8
+ t.text :content, null: false
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :purizumu_translations,
14
+ %i[model_class_name record_id attribute_name locale],
15
+ unique: true,
16
+ name: "index_purizumu_translations_uniqueness"
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ # Stores runtime configuration used by translation engines.
5
+ class Configuration
6
+ attr_accessor :base_url, :engine, :key, :model, :source_locale, :transport
7
+
8
+ def initialize
9
+ @engine = :xai
10
+ @base_url = 'https://api.x.ai/v1'
11
+ @source_locale = nil
12
+ @transport = nil
13
+ end
14
+
15
+ def resolved_source_locale
16
+ (source_locale || I18n.default_locale).to_s
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ module Engines
5
+ class Xai
6
+ # Builds the tool-enforced request payload sent to xAI.
7
+ class PayloadBuilder
8
+ TOOL_NAME = Xai::TOOL_NAME
9
+
10
+ def initialize(configuration:, request:)
11
+ @configuration = configuration
12
+ @request = request
13
+ end
14
+
15
+ def to_json(*_args)
16
+ {
17
+ model: configuration.model,
18
+ messages: messages,
19
+ tools: [tool_definition],
20
+ tool_choice: tool_choice
21
+ }.to_json
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :configuration, :request
27
+
28
+ def messages
29
+ [system_message, user_message]
30
+ end
31
+
32
+ def system_message
33
+ {
34
+ role: 'system',
35
+ content: 'You translate Rails model attributes faithfully and concisely.'
36
+ }
37
+ end
38
+
39
+ def user_message
40
+ {
41
+ role: 'user',
42
+ content: prompt
43
+ }
44
+ end
45
+
46
+ def prompt
47
+ <<~PROMPT
48
+ Translate the following model attribute.
49
+
50
+ Model: #{request.fetch(:model_name)}
51
+ Attribute: #{request.fetch(:attribute_name)}
52
+ Source locale: #{request.fetch(:source_locale)}
53
+ Target locale: #{request.fetch(:locale)}
54
+ Original content: #{request.fetch(:content)}
55
+ PROMPT
56
+ end
57
+
58
+ def tool_definition
59
+ {
60
+ type: 'function',
61
+ function: {
62
+ name: TOOL_NAME,
63
+ description: 'Return the translated text for the requested locale.',
64
+ parameters: tool_parameters
65
+ }
66
+ }
67
+ end
68
+
69
+ def tool_parameters
70
+ {
71
+ type: 'object',
72
+ properties: {
73
+ translated_text: {
74
+ type: 'string',
75
+ description: 'The translated version of the original content.'
76
+ }
77
+ },
78
+ required: ['translated_text'],
79
+ additionalProperties: false
80
+ }
81
+ end
82
+
83
+ def tool_choice
84
+ {
85
+ type: 'function',
86
+ function: { name: TOOL_NAME }
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ module Engines
5
+ class Xai
6
+ # Extracts the required tool payload from xAI responses.
7
+ class ResponseParser
8
+ def initialize(response:)
9
+ @response = response
10
+ end
11
+
12
+ def translated_text
13
+ raise TranslationError, failure_message if response_error?
14
+
15
+ extract_translation(parsed_body)
16
+ rescue JSON::ParserError => e
17
+ raise TranslationError, "Unable to parse xAI response: #{e.message}"
18
+ rescue KeyError => e
19
+ raise TranslationError, "xAI response missing translation payload: #{e.message}"
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :response
25
+
26
+ def parsed_body
27
+ @parsed_body ||= JSON.parse(response.body)
28
+ end
29
+
30
+ def response_error?
31
+ !response.is_a?(Net::HTTPSuccess)
32
+ end
33
+
34
+ def failure_message
35
+ "xAI request failed with status #{response.code}"
36
+ end
37
+
38
+ def extract_translation(body)
39
+ tool_call = body.dig('choices', 0, 'message', 'tool_calls', 0, 'function')
40
+ raise TranslationError, missing_tool_message unless tool_call
41
+
42
+ arguments = JSON.parse(tool_call.fetch('arguments'))
43
+ translation = arguments.fetch('translated_text').to_s
44
+ raise TranslationError, empty_translation_message if translation.empty?
45
+
46
+ translation
47
+ end
48
+
49
+ def missing_tool_message
50
+ 'xAI response did not include the required translation tool call'
51
+ end
52
+
53
+ def empty_translation_message
54
+ 'xAI response returned an empty translation'
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ module Engines
5
+ # Translation engine that uses xAI and forces a function tool response.
6
+ class Xai
7
+ TOOL_NAME = 'return_translation'
8
+
9
+ def initialize(configuration:)
10
+ @configuration = configuration
11
+ end
12
+
13
+ def translate(model_name:, attribute_name:, content:, locale:, source_locale:)
14
+ validate_configuration!
15
+
16
+ response = transport.call(
17
+ uri,
18
+ request_headers,
19
+ request_body(model_name:, attribute_name:, content:, locale:, source_locale:)
20
+ )
21
+
22
+ ResponseParser.new(response:).translated_text
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :configuration
28
+
29
+ def validate_configuration!
30
+ if configuration.key.to_s.empty?
31
+ raise ConfigurationError, 'Purizumu xAI engine requires config.key'
32
+ end
33
+ return unless configuration.model.to_s.empty?
34
+
35
+ raise ConfigurationError, 'Purizumu xAI engine requires config.model'
36
+ end
37
+
38
+ def uri
39
+ base_url = configuration.base_url
40
+ base = base_url.end_with?('/') ? base_url : "#{base_url}/"
41
+ URI.join(base, 'chat/completions')
42
+ end
43
+
44
+ def request_headers
45
+ {
46
+ 'Authorization' => "Bearer #{configuration.key}",
47
+ 'Content-Type' => 'application/json'
48
+ }
49
+ end
50
+
51
+ def request_body(model_name:, attribute_name:, content:, locale:, source_locale:)
52
+ request = { model_name:, attribute_name:, content:, locale:, source_locale: }
53
+ PayloadBuilder.new(configuration:, request:).to_json
54
+ end
55
+
56
+ def transport
57
+ configuration.transport || method(:default_transport)
58
+ end
59
+
60
+ def default_transport(uri, headers, body)
61
+ http = Net::HTTP.new(uri.host, uri.port)
62
+ http.use_ssl = uri.scheme == 'https'
63
+
64
+ request = Net::HTTP::Post.new(uri)
65
+ headers.each { |key, value| request[key] = value }
66
+ request.body = body
67
+
68
+ http.request(request)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class TranslationError < Error; end
7
+ class UnsupportedEngineError < Error; end
8
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ # Adds the class-level DSL for declaring translated attributes.
5
+ module ModelExtensions
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ # Defines translated attribute readers on Active Record models.
11
+ module ClassMethods
12
+ def attribute_translation(*attribute_names)
13
+ attribute_names.flatten.each do |attribute_name|
14
+ define_method(attribute_name) do
15
+ Purizumu.translate_attribute(self, attribute_name)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+ require 'generators/purizumu/install/install_generator'
5
+
6
+ module Purizumu
7
+ # Hooks Purizumu into Rails so models can use the translation DSL.
8
+ class Railtie < Rails::Railtie
9
+ initializer 'purizumu.active_record' do
10
+ ActiveSupport.on_load(:active_record) do
11
+ include Purizumu::ModelExtensions
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ # Active Record model that persists generated translations by record and locale.
5
+ class Translation < ActiveRecord::Base
6
+ self.table_name = 'purizumu_translations'
7
+
8
+ validates :attribute_name, :content, :locale, :model_class_name, :record_id, presence: true
9
+ validates :locale, uniqueness: { scope: %i[model_class_name record_id attribute_name] }
10
+ end
11
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ # Resolves a translated attribute, generating and caching it on demand.
5
+ class Translator
6
+ def initialize(record:, attribute_name:, locale:)
7
+ @record = record
8
+ @attribute_name = attribute_name.to_s
9
+ @locale = locale.to_s
10
+ end
11
+
12
+ def call
13
+ return source_content if source_content.nil? || source_content == ''
14
+
15
+ existing_translation&.content || create_translation!.content
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :attribute_name, :locale, :record
21
+
22
+ def create_translation!
23
+ Translation.find_or_create_by!(
24
+ model_class_name: record.class.name,
25
+ record_id: record.id,
26
+ attribute_name: attribute_name,
27
+ locale: locale
28
+ ) do |translation|
29
+ translation.content = translated_content
30
+ end
31
+ end
32
+
33
+ def existing_translation
34
+ @existing_translation ||= Translation.find_by(
35
+ model_class_name: record.class.name,
36
+ record_id: record.id,
37
+ attribute_name: attribute_name,
38
+ locale: locale
39
+ )
40
+ end
41
+
42
+ def translated_content
43
+ return source_content if locale == source_locale
44
+
45
+ Purizumu.engine.translate(
46
+ model_name: record.class.name,
47
+ attribute_name: attribute_name,
48
+ content: source_content,
49
+ locale: locale,
50
+ source_locale: source_locale
51
+ )
52
+ end
53
+
54
+ def source_content
55
+ record.read_attribute(attribute_name)
56
+ end
57
+
58
+ def source_locale
59
+ Purizumu.configuration.resolved_source_locale
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purizumu
4
+ VERSION = '0.1.0'
5
+ end
data/lib/purizumu.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'i18n'
5
+ require 'json'
6
+ require 'net/http'
7
+ require 'uri'
8
+
9
+ require 'purizumu/version'
10
+ require 'purizumu/errors'
11
+ require 'purizumu/configuration'
12
+ require 'purizumu/engines/xai'
13
+ require 'purizumu/engines/xai/payload_builder'
14
+ require 'purizumu/engines/xai/response_parser'
15
+ require 'purizumu/translation'
16
+ require 'purizumu/model_extensions'
17
+ require 'purizumu/translator'
18
+ require 'purizumu/railtie' if defined?(Rails::Railtie)
19
+
20
+ # Entry point and global configuration for Purizumu translations.
21
+ module Purizumu
22
+ class << self
23
+ def configure
24
+ yield(configuration)
25
+ end
26
+
27
+ def configuration
28
+ @configuration ||= Configuration.new
29
+ end
30
+
31
+ def reset_configuration!
32
+ @configuration = Configuration.new
33
+ end
34
+
35
+ def translate_attribute(record, attribute_name, locale: I18n.locale)
36
+ Translator.new(record: record, attribute_name: attribute_name, locale: locale).call
37
+ end
38
+
39
+ def engine
40
+ engine_name = configuration.engine.to_sym
41
+
42
+ case engine_name
43
+ when :xai
44
+ Engines::Xai.new(configuration: configuration)
45
+ else
46
+ raise UnsupportedEngineError, "Unsupported translation engine: #{engine_name}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ ActiveRecord::Base.include(Purizumu::ModelExtensions) if defined?(ActiveRecord::Base)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Purizumu::Configuration do
6
+ subject(:config) { described_class.new }
7
+
8
+ before do
9
+ Purizumu.configure do |config|
10
+ config.engine = :xai
11
+ config.key = 'secret'
12
+ config.model = 'grok-4.20-reasoning'
13
+ config.source_locale = :ja
14
+ end
15
+ end
16
+
17
+ it { expect(config.engine).to eq(:xai) }
18
+ it { expect(config.base_url).to eq('https://api.x.ai/v1') }
19
+ it { expect(config.resolved_source_locale).to eq('en') }
20
+
21
+ describe '.configure' do
22
+ subject(:configured) { Purizumu.configuration }
23
+
24
+ it { expect(configured.key).to eq('secret') }
25
+ it { expect(configured.model).to eq('grok-4.20-reasoning') }
26
+ it { expect(configured.resolved_source_locale).to eq('ja') }
27
+ end
28
+
29
+ it 'raises for unsupported engines' do
30
+ Purizumu.configure { |config| config.engine = :bogus }
31
+
32
+ expect { Purizumu.engine }.to raise_error(Purizumu::UnsupportedEngineError, /bogus/)
33
+ end
34
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Purizumu::Engines::Xai do
6
+ subject(:translate_call) do
7
+ engine.translate(
8
+ model_name: 'Gem',
9
+ attribute_name: 'human_name',
10
+ content: 'Prism',
11
+ locale: 'jp',
12
+ source_locale: 'en'
13
+ )
14
+ end
15
+
16
+ let(:transport) { instance_spy(Proc) }
17
+ let(:captured_request) { {} }
18
+ let(:configuration) do
19
+ Purizumu::Configuration.new.tap do |config|
20
+ config.key = 'token'
21
+ config.model = 'grok-test'
22
+ config.transport = transport
23
+ end
24
+ end
25
+ let(:engine) { described_class.new(configuration: configuration) }
26
+
27
+ describe '#translate' do
28
+ before do
29
+ allow(transport).to receive(:call) do |uri, headers, body|
30
+ captured_request[:uri] = uri
31
+ captured_request[:headers] = headers
32
+ captured_request[:body] = body
33
+ successful_translation_response
34
+ end
35
+ end
36
+
37
+ it 'returns the translated text' do
38
+ expect(translate_call).to eq('プリズム')
39
+ end
40
+
41
+ it 'calls the xAI chat completions endpoint' do
42
+ translate_call
43
+
44
+ expect(request_uri.to_s).to eq('https://api.x.ai/v1/chat/completions')
45
+ end
46
+
47
+ it 'sends the authorization header' do
48
+ translate_call
49
+
50
+ expect(request_headers['Authorization']).to eq('Bearer token')
51
+ end
52
+
53
+ it 'forces the translation tool choice' do
54
+ translate_call
55
+
56
+ expect(parsed_request_body['tool_choice']).to eq(
57
+ { 'type' => 'function', 'function' => { 'name' => 'return_translation' } }
58
+ )
59
+ end
60
+
61
+ it 'requires the translated_text tool parameter' do
62
+ translate_call
63
+
64
+ required = parsed_request_body['tools'].first.dig('function', 'parameters', 'required')
65
+ expect(required).to eq(['translated_text'])
66
+ end
67
+
68
+ it 'raises when key is missing' do
69
+ configuration.key = nil
70
+
71
+ expect { translate_call }.to raise_error(Purizumu::ConfigurationError, /config.key/)
72
+ end
73
+
74
+ it 'raises when model is missing' do
75
+ configuration.model = nil
76
+
77
+ expect { translate_call }.to raise_error(Purizumu::ConfigurationError, /config.model/)
78
+ end
79
+
80
+ it 'raises when the response does not contain a tool call' do
81
+ allow(transport).to receive(:call).and_return(response_with_body(choices: [{ message: {} }]))
82
+
83
+ expect { translate_call }.to raise_error(
84
+ Purizumu::TranslationError,
85
+ /required translation tool call/
86
+ )
87
+ end
88
+
89
+ it 'raises when the response body is invalid json' do
90
+ allow(transport).to receive(:call).and_return(response_with_raw_body(Net::HTTPOK, 'not json'))
91
+
92
+ expect { translate_call }.to raise_error(Purizumu::TranslationError, /Unable to parse/)
93
+ end
94
+
95
+ it 'raises when the http status is unsuccessful' do
96
+ bad_response = response_with_raw_body(Net::HTTPBadRequest, { error: 'bad request' }.to_json,
97
+ code: '400', message: 'Bad Request')
98
+ allow(transport).to receive(:call).and_return(bad_response)
99
+
100
+ expect { translate_call }.to raise_error(Purizumu::TranslationError, /status 400/)
101
+ end
102
+
103
+ it 'raises when translated_text is missing' do
104
+ allow(transport).to receive(:call).and_return(response_with_body(missing_translation_body))
105
+
106
+ expect { translate_call }.to raise_error(
107
+ Purizumu::TranslationError,
108
+ /missing translation payload/
109
+ )
110
+ end
111
+ end
112
+
113
+ def request_uri
114
+ captured_request.fetch(:uri)
115
+ end
116
+
117
+ def request_headers
118
+ captured_request.fetch(:headers)
119
+ end
120
+
121
+ def parsed_request_body
122
+ JSON.parse(captured_request.fetch(:body))
123
+ end
124
+
125
+ def response_with_body(body)
126
+ response_with_raw_body(Net::HTTPOK, body.to_json)
127
+ end
128
+
129
+ def successful_translation_response
130
+ response_with_body(
131
+ choices: [
132
+ {
133
+ message: {
134
+ tool_calls: [
135
+ {
136
+ function: {
137
+ arguments: { translated_text: 'プリズム' }.to_json
138
+ }
139
+ }
140
+ ]
141
+ }
142
+ }
143
+ ]
144
+ )
145
+ end
146
+
147
+ def missing_translation_body
148
+ { choices: [{ message: { tool_calls: [{ function: { arguments: {}.to_json } }] } }] }
149
+ end
150
+
151
+ def response_with_raw_body(klass, body, code: '200', message: 'OK')
152
+ klass.new('1.1', code, message).tap do |response|
153
+ allow(response).to receive(:body).and_return(body)
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'generators/purizumu/install/install_generator'
5
+
6
+ RSpec.describe Purizumu::Generators::InstallGenerator do
7
+ let(:destination_root) { File.expand_path('../../tmp/generator', __dir__) }
8
+ let(:migration_path) do
9
+ Dir[File.join(destination_root, 'db/migrate/*_create_purizumu_translations.rb')].first
10
+ end
11
+ let(:migration_content) { File.read(migration_path) }
12
+
13
+ before do
14
+ FileUtils.rm_rf(destination_root)
15
+ FileUtils.mkdir_p(destination_root)
16
+ described_class.start([], destination_root:)
17
+ end
18
+
19
+ it 'creates the install migration' do
20
+ expect(migration_path).to be_present
21
+ end
22
+
23
+ it 'includes the translation table definition' do
24
+ expect(migration_content).to include('create_table :purizumu_translations')
25
+ end
26
+
27
+ it 'includes the record id column' do
28
+ expect(migration_content).to include('t.bigint :record_id, null: false')
29
+ end
30
+
31
+ it 'includes the unique composite index' do
32
+ expect(migration_content).to include('%i[model_class_name record_id attribute_name locale]')
33
+ end
34
+
35
+ it 'marks the composite index as unique' do
36
+ expect(migration_content).to include('unique: true')
37
+ end
38
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Purizumu::Translator do
6
+ subject(:translated_name) { record.human_name }
7
+
8
+ before do
9
+ stub_const('GemRecord', Class.new(ActiveRecord::Base) do
10
+ self.table_name = 'gems'
11
+
12
+ attribute_translation :human_name
13
+ end)
14
+ end
15
+
16
+ let!(:record) { GemRecord.create!(human_name: 'Prism') }
17
+ let(:translation_scope) do
18
+ Purizumu::Translation.where(
19
+ model_class_name: 'GemRecord',
20
+ record_id: record.id,
21
+ attribute_name: 'human_name'
22
+ )
23
+ end
24
+ let(:existing_translation_attributes) do
25
+ {
26
+ model_class_name: 'GemRecord',
27
+ record_id: record.id,
28
+ attribute_name: 'human_name',
29
+ locale: 'jp',
30
+ content: 'プリズム'
31
+ }
32
+ end
33
+ let(:engine) { instance_double(Purizumu::Engines::Xai, translate: 'Prisma') }
34
+
35
+ describe '#call' do
36
+ before do
37
+ allow(Purizumu).to receive(:engine).and_call_original
38
+ end
39
+
40
+ it 'returns the original content for the source locale' do
41
+ I18n.locale = :en
42
+
43
+ expect(translated_name).to eq('Prism')
44
+ end
45
+
46
+ it 'stores the source locale translation' do
47
+ I18n.locale = :en
48
+ translated_name
49
+
50
+ expect(translation_scope.find_by!(locale: 'en').content).to eq('Prism')
51
+ end
52
+
53
+ it 'returns an existing translation' do
54
+ create_existing_translation
55
+ I18n.locale = :jp
56
+
57
+ expect(translated_name).to eq('プリズム')
58
+ end
59
+
60
+ it 'does not invoke the engine for an existing translation' do
61
+ create_existing_translation
62
+ I18n.locale = :jp
63
+ translated_name
64
+
65
+ expect(Purizumu).not_to have_received(:engine)
66
+ end
67
+
68
+ it 'returns a generated translation when missing' do
69
+ engine = instance_double(Purizumu::Engines::Xai)
70
+ allow(Purizumu).to receive(:engine).and_return(engine)
71
+ allow(engine).to receive(:translate).and_return('Призма')
72
+
73
+ I18n.locale = :ru
74
+
75
+ expect(translated_name).to eq('Призма')
76
+ end
77
+
78
+ it 'persists a generated translation when missing' do
79
+ engine = instance_double(Purizumu::Engines::Xai, translate: 'Призма')
80
+ allow(Purizumu).to receive(:engine).and_return(engine)
81
+
82
+ I18n.locale = :ru
83
+ translated_name
84
+
85
+ expect(translation_scope.find_by!(locale: 'ru').content).to eq('Призма')
86
+ end
87
+
88
+ it 'passes model and locale details to the engine' do
89
+ allow(Purizumu).to receive(:engine).and_return(engine)
90
+ I18n.locale = :es
91
+ translated_name
92
+
93
+ expect(engine).to have_received(:translate).with(expected_engine_arguments)
94
+ end
95
+
96
+ it 'returns blank content without creating a translation' do
97
+ blank_record = GemRecord.create!(human_name: '')
98
+
99
+ I18n.locale = :jp
100
+
101
+ expect(blank_record.human_name).to eq('')
102
+ end
103
+
104
+ it 'does not create a translation for blank content' do
105
+ blank_record = GemRecord.create!(human_name: '')
106
+
107
+ I18n.locale = :jp
108
+ blank_record.human_name
109
+
110
+ expect(Purizumu::Translation.where(record_id: blank_record.id)).to be_empty
111
+ end
112
+ end
113
+
114
+ def create_existing_translation
115
+ Purizumu::Translation.create!(existing_translation_attributes)
116
+ end
117
+
118
+ def expected_engine_arguments
119
+ {
120
+ model_name: 'GemRecord',
121
+ attribute_name: 'human_name',
122
+ content: 'Prism',
123
+ locale: 'es',
124
+ source_locale: 'en'
125
+ }
126
+ end
127
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+
6
+ require 'bundler/setup'
7
+ require 'active_record'
8
+ require 'active_support'
9
+ require 'active_support/core_ext/object/blank'
10
+ require 'rails'
11
+ require 'rails/generators'
12
+ require 'rspec'
13
+
14
+ require 'purizumu'
15
+
16
+ RSpec.configure do |config|
17
+ config.before(:suite) do
18
+ I18n.available_locales = %i[en es jp ru]
19
+ I18n.default_locale = :en
20
+
21
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
22
+
23
+ ActiveRecord::Schema.define do
24
+ suppress_messages do
25
+ create_table :gems, force: true do |t|
26
+ t.string :human_name
27
+ end
28
+
29
+ create_table :purizumu_translations, force: true do |t|
30
+ t.string :model_class_name, null: false
31
+ t.bigint :record_id, null: false
32
+ t.string :attribute_name, null: false
33
+ t.string :locale, null: false
34
+ t.text :content, null: false
35
+ t.timestamps null: false
36
+ end
37
+
38
+ add_index :purizumu_translations,
39
+ %i[model_class_name record_id attribute_name locale],
40
+ unique: true,
41
+ name: 'index_purizumu_translations_uniqueness'
42
+ end
43
+ end
44
+ end
45
+
46
+ config.before do
47
+ Purizumu.reset_configuration!
48
+ I18n.locale = I18n.default_locale
49
+ Purizumu::Translation.delete_all
50
+ end
51
+
52
+ config.after(:suite) do
53
+ ActiveRecord::Base.connection.disconnect!
54
+ end
55
+ end
@@ -0,0 +1,18 @@
1
+ class CreatePurizumuTranslations < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :purizumu_translations do |t|
4
+ t.string :model_class_name, null: false
5
+ t.bigint :record_id, null: false
6
+ t.string :attribute_name, null: false
7
+ t.string :locale, null: false
8
+ t.text :content, null: false
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :purizumu_translations,
14
+ %i[model_class_name record_id attribute_name locale],
15
+ unique: true,
16
+ name: "index_purizumu_translations_uniqueness"
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: purizumu
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - OpenAI Codex
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: i18n
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.14'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.14'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ description: Purizumu lazily translates Rails model attributes into multiple locales
55
+ and caches them in the database.
56
+ email:
57
+ - noreply@example.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - README.md
63
+ - Rakefile
64
+ - lib/generators/purizumu/install/install_generator.rb
65
+ - lib/generators/purizumu/install/templates/create_purizumu_translations.rb.erb
66
+ - lib/purizumu.rb
67
+ - lib/purizumu/configuration.rb
68
+ - lib/purizumu/engines/xai.rb
69
+ - lib/purizumu/engines/xai/payload_builder.rb
70
+ - lib/purizumu/engines/xai/response_parser.rb
71
+ - lib/purizumu/errors.rb
72
+ - lib/purizumu/model_extensions.rb
73
+ - lib/purizumu/railtie.rb
74
+ - lib/purizumu/translation.rb
75
+ - lib/purizumu/translator.rb
76
+ - lib/purizumu/version.rb
77
+ - spec/purizumu/configuration_spec.rb
78
+ - spec/purizumu/engines/xai_spec.rb
79
+ - spec/purizumu/generators/install_generator_spec.rb
80
+ - spec/purizumu/translator_spec.rb
81
+ - spec/spec_helper.rb
82
+ - spec/tmp/generator/db/migrate/20260412193408_create_purizumu_translations.rb
83
+ homepage: https://example.com/purizumu
84
+ licenses:
85
+ - MIT
86
+ metadata:
87
+ homepage_uri: https://example.com/purizumu
88
+ source_code_uri: https://example.com/purizumu
89
+ rubygems_mfa_required: 'true'
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '3.3'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 4.0.4
105
+ specification_version: 4
106
+ summary: Auto-translate Rails model attributes and persist the results.
107
+ test_files: []