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 +7 -0
- data/README.md +158 -0
- data/Rakefile +8 -0
- data/lib/generators/purizumu/install/install_generator.rb +26 -0
- data/lib/generators/purizumu/install/templates/create_purizumu_translations.rb.erb +18 -0
- data/lib/purizumu/configuration.rb +19 -0
- data/lib/purizumu/engines/xai/payload_builder.rb +92 -0
- data/lib/purizumu/engines/xai/response_parser.rb +59 -0
- data/lib/purizumu/engines/xai.rb +72 -0
- data/lib/purizumu/errors.rb +8 -0
- data/lib/purizumu/model_extensions.rb +21 -0
- data/lib/purizumu/railtie.rb +15 -0
- data/lib/purizumu/translation.rb +11 -0
- data/lib/purizumu/translator.rb +62 -0
- data/lib/purizumu/version.rb +5 -0
- data/lib/purizumu.rb +52 -0
- data/spec/purizumu/configuration_spec.rb +34 -0
- data/spec/purizumu/engines/xai_spec.rb +156 -0
- data/spec/purizumu/generators/install_generator_spec.rb +38 -0
- data/spec/purizumu/translator_spec.rb +127 -0
- data/spec/spec_helper.rb +55 -0
- data/spec/tmp/generator/db/migrate/20260412193408_create_purizumu_translations.rb +18 -0
- metadata +107 -0
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,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,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
|
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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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: []
|