blazer-ai 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5383dbf30daabea740fc3a6623310bd7d2c79bdc80e8a9ebb682f36b1660a52
4
- data.tar.gz: cc47d89bc105c77af5ce838c09caa3cd9bded14e70710888284dfd1ba1fd1a20
3
+ metadata.gz: ae37ca646c57731bc74109540de81eba74164b71f4f057f98060c54127167848
4
+ data.tar.gz: 649ebd6b0667fa5a41035dabf3b94e8d077c9627c491f05e01642f67e8bf53fd
5
5
  SHA512:
6
- metadata.gz: 1ebe828b2b7f50b44d1f63b9ec0595bcf972c0136ea79da9d0bd8072f696205cd43ab766ec487fbb369e96c544b9027c68b474fa4f70e06b193aad9a4718b891
7
- data.tar.gz: 48a86302e19684549ef22cd19d9b60e9013c91128b489f073f48c508915c47f18471c8a7a6290c073babd0f10633d6ecc289cdb6e9603cdbd04e83189033a662
6
+ metadata.gz: a7d1f3812b5e8b159e6dab2782134587f8c23b3faf4797a95fbcff4f3e3af330bee39b8dfaec80a57d6e4eea00d8de9b896bc7c5c49dbc99da17d0293589f0fb
7
+ data.tar.gz: dc6ab6edd63c9f76dc498b2ebf55108b108729052ad136f291c722b5f248d95268f53e5878fdb4a23a52f0dd77d2fe4692266be999df4d2cc1d1b270c8d2862a
@@ -1,7 +1,7 @@
1
1
  # Handles AI-powered SQL generation requests.
2
2
  # Validates rate limits, sanitizes input, and returns generated SQL.
3
3
  class Blazer::Ai::QueriesController < Blazer::Ai::ApplicationController
4
- before_action :ensure_ai_enabled, only: [ :create ]
4
+ before_action :ensure_ai_enabled, only: [:create]
5
5
 
6
6
  def create
7
7
  rate_limiter.check_and_track!(identifier: current_identifier)
@@ -12,17 +12,17 @@ class Blazer::Ai::QueriesController < Blazer::Ai::ApplicationController
12
12
  sql = generator.call
13
13
  log_generation(query_params, sql)
14
14
 
15
- render json: { sql: sql }
15
+ render json: {sql: sql}
16
16
  rescue SqlValidator::ValidationError
17
- render json: { error: "Generated SQL failed safety validation" }, status: :unprocessable_entity
17
+ render json: {error: "Generated SQL failed safety validation"}, status: :unprocessable_entity
18
18
  rescue SqlGenerator::GenerationError => e
19
- render json: { error: e.message }, status: :unprocessable_entity
19
+ render json: {error: e.message}, status: :unprocessable_entity
20
20
  rescue RateLimiter::RateLimitExceeded => e
21
- render json: { error: e.message, retry_after: e.retry_after }, status: :too_many_requests
22
- rescue StandardError => e
21
+ render json: {error: e.message, retry_after: e.retry_after}, status: :too_many_requests
22
+ rescue => e
23
23
  Rails.logger.error("[BlazerAI] Generation error: #{e.class}: #{e.message}")
24
24
  Rails.logger.error(e.backtrace.first(10).join("\n")) if e.backtrace
25
- render json: { error: "An error occurred while generating SQL. Please try again." }, status: :unprocessable_entity
25
+ render json: {error: "An error occurred while generating SQL. Please try again."}, status: :unprocessable_entity
26
26
  end
27
27
 
28
28
  private
@@ -58,7 +58,7 @@ class Blazer::Ai::QueriesController < Blazer::Ai::ApplicationController
58
58
  end
59
59
 
60
60
  def ensure_ai_enabled
61
- render json: { error: "AI features are disabled" }, status: :forbidden unless Blazer::Ai.configuration.enabled?
61
+ render json: {error: "AI features are disabled"}, status: :forbidden unless Blazer::Ai.configuration.enabled?
62
62
  end
63
63
 
64
64
  def rate_limiter
@@ -24,4 +24,4 @@
24
24
  <%= link_to "Cancel", queries_path, class: "btn btn-default" %>
25
25
  </div>
26
26
  </div>
27
- <% end %>
27
+ <% end %>
@@ -207,4 +207,4 @@
207
207
  BlazerAI.init();
208
208
  }
209
209
  })();
210
- </script>
210
+ </script>
@@ -1,11 +1,11 @@
1
1
  # Configuration for Blazer AI.
2
2
  class Blazer::Ai::Configuration
3
3
  attr_accessor :enabled, :default_model, :temperature, :rate_limit_per_minute,
4
- :schema_cache_ttl, :max_prompt_length, :max_sql_length
4
+ :schema_cache_ttl, :max_prompt_length, :max_sql_length
5
5
 
6
6
  def initialize
7
7
  @enabled = true
8
- @default_model = "gpt-5.1-codex"
8
+ @default_model = "gpt-4o-mini"
9
9
  @temperature = 0.2
10
10
  @rate_limit_per_minute = 20
11
11
  @schema_cache_ttl = 12.hours
@@ -1,5 +1,8 @@
1
1
  # Rails engine for Blazer AI.
2
- # Views can be overridden by creating app/views/blazer/ai/queries/_generate_sql_button.html.erb
3
- class Blazer::Ai::Engine < ::Rails::Engine
4
- isolate_namespace Blazer::Ai
2
+ module Blazer
3
+ module Ai
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Blazer::Ai
6
+ end
7
+ end
5
8
  end
@@ -0,0 +1,219 @@
1
+ # Rack middleware that injects the AI button into Blazer query pages
2
+ # and handles the AI generation API endpoint.
3
+ module Blazer
4
+ module Ai
5
+ class Middleware
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ # Handle AI generation endpoint
12
+ if ai_generate_request?(env)
13
+ return handle_ai_generate(env)
14
+ end
15
+
16
+ status, headers, response = @app.call(env)
17
+
18
+ # Only inject into HTML responses for Blazer query pages
19
+ if inject_button?(env, headers)
20
+ body = extract_body(response)
21
+ if body.include?("id=\"editor\"") || body.include?("id=\"editor-container\"")
22
+ body = inject_ai_script(body)
23
+ headers["Content-Length"] = body.bytesize.to_s
24
+ response = [body]
25
+ end
26
+ end
27
+
28
+ [status, headers, response]
29
+ end
30
+
31
+ private
32
+
33
+ def ai_generate_request?(env)
34
+ env["REQUEST_METHOD"] == "POST" &&
35
+ env["PATH_INFO"].to_s.end_with?("/ai/generate_sql")
36
+ end
37
+
38
+ def handle_ai_generate(env)
39
+ require "json"
40
+
41
+ request = Rack::Request.new(env)
42
+ params = begin
43
+ JSON.parse(request.body.read)
44
+ rescue
45
+ {}
46
+ end
47
+ query_params = params["query"] || {}
48
+
49
+ # Basic validation
50
+ name = query_params["name"].to_s.strip
51
+ description = query_params["description"].to_s.strip
52
+
53
+ if name.empty? && description.empty?
54
+ return json_response({error: "Name or description required"}, 422)
55
+ end
56
+
57
+ # Check if AI is enabled
58
+ unless Blazer::Ai.configuration.enabled?
59
+ return json_response({error: "AI features are disabled"}, 403)
60
+ end
61
+
62
+ # Rate limiting
63
+ identifier = "ip:#{request.ip}"
64
+ rate_limiter = RateLimiter.new
65
+ begin
66
+ rate_limiter.check_and_track!(identifier: identifier)
67
+ rescue RateLimiter::RateLimitExceeded => e
68
+ return json_response({error: e.message, retry_after: e.retry_after}, 429)
69
+ end
70
+
71
+ # Find data source
72
+ data_source = find_data_source(query_params["data_source"])
73
+
74
+ # Generate SQL
75
+ generator = SqlGenerator.new(
76
+ params: {name: name, description: description},
77
+ data_source: data_source
78
+ )
79
+
80
+ begin
81
+ sql = generator.call
82
+ json_response({sql: sql}, 200)
83
+ rescue SqlValidator::ValidationError
84
+ json_response({error: "Generated SQL failed safety validation"}, 422)
85
+ rescue SqlGenerator::GenerationError => e
86
+ json_response({error: e.message}, 422)
87
+ rescue => e
88
+ Rails.logger.error("[BlazerAI] Generation error: #{e.class}: #{e.message}") if defined?(Rails.logger)
89
+ json_response({error: "An error occurred while generating SQL"}, 422)
90
+ end
91
+ end
92
+
93
+ def find_data_source(data_source_id)
94
+ return nil if data_source_id.to_s.empty?
95
+ return nil unless defined?(Blazer) && Blazer.respond_to?(:data_sources)
96
+ Blazer.data_sources[data_source_id]
97
+ end
98
+
99
+ def json_response(data, status)
100
+ body = JSON.generate(data)
101
+ [
102
+ status,
103
+ {"Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s},
104
+ [body]
105
+ ]
106
+ end
107
+
108
+ def inject_button?(env, headers)
109
+ return false unless headers["Content-Type"]&.include?("text/html")
110
+ path = env["PATH_INFO"].to_s
111
+ path.match?(%r{/queries(/new|/\d+(/edit)?)?$})
112
+ end
113
+
114
+ def extract_body(response)
115
+ body = +""
116
+ response.each { |part| body << part }
117
+ response.close if response.respond_to?(:close)
118
+ body
119
+ end
120
+
121
+ def inject_ai_script(body)
122
+ generate_path = Blazer::Ai::UrlHelper.blazer_ai_generate_sql_path
123
+ script = build_script(generate_path)
124
+
125
+ # Find the last </body> tag using rindex to avoid regex issues
126
+ closing_body_index = body.rindex("</body>")
127
+ return body unless closing_body_index
128
+
129
+ # Insert script before the closing body tag by manual string slicing
130
+ body[0...closing_body_index] + script + body[closing_body_index..-1]
131
+ end
132
+
133
+ def build_script(generate_path)
134
+ <<~HTML
135
+ <style>
136
+ #blazer-ai-btn { margin-right: 5px; cursor: pointer; }
137
+ #blazer-ai-btn.loading { opacity: 0.7; }
138
+ #blazer-ai-btn .spinner { display: none; width: 12px; height: 12px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: bas 0.8s linear infinite; margin-right: 4px; vertical-align: middle; }
139
+ #blazer-ai-btn.loading .spinner { display: inline-block; }
140
+ @keyframes bas { to { transform: rotate(360deg); } }
141
+ #blazer-ai-error { display: none; margin-top: 8px; padding: 8px 12px; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; color: #721c24; font-size: 13px; }
142
+ #blazer-ai-error.show { display: block; }
143
+ </style>
144
+ <script>
145
+ (function() {
146
+ var BA = {
147
+ loading: false,
148
+ path: "#{generate_path}",
149
+ init: function() {
150
+ var self = this, attempts = 0;
151
+ var iv = setInterval(function() {
152
+ var btn = document.querySelector('input.btn-success[type="submit"][value="Create"], input.btn-success[type="submit"][value="Update"]');
153
+ if (btn && !document.getElementById('blazer-ai-btn')) {
154
+ clearInterval(iv);
155
+ self.inject(btn);
156
+ } else if (++attempts > 50) clearInterval(iv);
157
+ }, 100);
158
+ document.addEventListener('keydown', function(e) {
159
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'G') { e.preventDefault(); BA.generate(); }
160
+ });
161
+ },
162
+ inject: function(createBtn) {
163
+ var btn = document.createElement('a');
164
+ btn.id = 'blazer-ai-btn';
165
+ btn.className = 'btn btn-info';
166
+ btn.innerHTML = '<span class="spinner"></span>AI Generate';
167
+ btn.onclick = function(e) { e.preventDefault(); BA.generate(); };
168
+ createBtn.parentNode.insertBefore(btn, createBtn);
169
+ var err = document.createElement('div');
170
+ err.id = 'blazer-ai-error';
171
+ var closeBtn = document.createElement('span');
172
+ closeBtn.style.cssText = 'float:right;cursor:pointer';
173
+ closeBtn.textContent = 'x';
174
+ closeBtn.onclick = function() { err.classList.remove('show'); };
175
+ var msgSpan = document.createElement('span');
176
+ msgSpan.className = 'msg';
177
+ err.appendChild(closeBtn);
178
+ err.appendChild(msgSpan);
179
+ createBtn.parentNode.parentNode.appendChild(err);
180
+ },
181
+ generate: function() {
182
+ if (this.loading) return;
183
+ var name = (document.querySelector('input[name="query[name]"]') || {}).value || '';
184
+ var desc = (document.querySelector('textarea[name="query[description]"]') || {}).value || '';
185
+ var ds = (document.querySelector('select[name="query[data_source]"]') || {}).value || '';
186
+ if (!name.trim() && !desc.trim()) { this.error('Enter a query name or description first.'); return; }
187
+ this.setLoading(true);
188
+ this.hideError();
189
+ fetch(this.path, {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]') || {}).content || '' },
192
+ body: JSON.stringify({ query: { name: name, description: desc, data_source: ds } })
193
+ })
194
+ .then(function(r) { return r.json().then(function(d) { return { status: r.status, data: d }; }); })
195
+ .then(function(r) {
196
+ BA.setLoading(false);
197
+ if (r.status === 429) BA.error('Rate limit. Wait ' + (r.data.retry_after || 60) + 's.');
198
+ else if (r.status !== 200 || r.data.error) BA.error(r.data.error || 'Generation failed.');
199
+ else if (r.data.sql) BA.insertSQL(r.data.sql);
200
+ })
201
+ .catch(function() { BA.setLoading(false); BA.error('Network error.'); });
202
+ },
203
+ insertSQL: function(sql) {
204
+ if (typeof editor !== 'undefined') { editor.setValue(sql, 1); editor.focus(); }
205
+ else { var ta = document.querySelector('textarea[name="query[statement]"]'); if (ta) { ta.value = sql; ta.focus(); } }
206
+ },
207
+ setLoading: function(v) { this.loading = v; var b = document.getElementById('blazer-ai-btn'); if (b) b.classList.toggle('loading', v); },
208
+ error: function(m) { var e = document.getElementById('blazer-ai-error'); if (e) { e.querySelector('.msg').textContent = m; e.classList.add('show'); } },
209
+ hideError: function() { var e = document.getElementById('blazer-ai-error'); if (e) e.classList.remove('show'); }
210
+ };
211
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function() { BA.init(); });
212
+ else BA.init();
213
+ })();
214
+ </script>
215
+ HTML
216
+ end
217
+ end
218
+ end
219
+ end
@@ -1,26 +1,12 @@
1
1
  # Integrates Blazer AI into Rails applications.
2
- # Injects routes into Blazer::Engine and sets up view paths.
3
2
  class Blazer::Ai::Railtie < ::Rails::Railtie
4
- initializer "blazer_ai.load", before: :load_config_initializers do
3
+ initializer "blazer_ai.load" do
5
4
  require "blazer/ai/engine"
5
+ require "blazer/ai/middleware"
6
6
  end
7
7
 
8
- initializer "blazer_ai.prepend_view_paths", after: :load_config_initializers do |app|
9
- ActiveSupport.on_load(:action_controller) do
10
- append_view_path(Blazer::Ai::Engine.root.join("app/views"))
11
- end
12
- end
13
-
14
- # Inject routes directly into Blazer::Engine to avoid nested engine routing issues
15
- initializer "blazer_ai.extend_routes", after: "blazer.routes" do |app|
16
- if defined?(Blazer::Engine)
17
- Blazer::Engine.routes.prepend do
18
- post "/ai/generate_sql" => "ai/queries#create", as: "blazer_ai_generate_sql"
19
- end
20
-
21
- ActiveSupport.on_load(:action_controller) do
22
- helper Blazer::Ai::UrlHelper
23
- end
24
- end
8
+ # Middleware handles both UI injection and API endpoint
9
+ initializer "blazer_ai.middleware" do |app|
10
+ app.middleware.use Blazer::Ai::Middleware
25
11
  end
26
12
  end
@@ -37,7 +37,7 @@ module Blazer::Ai
37
37
  columns = connection.columns(table_name).map do |col|
38
38
  "#{col.name} (#{col.sql_type})"
39
39
  end
40
- "#{table_name}: #{columns.join(', ')}"
40
+ "#{table_name}: #{columns.join(", ")}"
41
41
  end.join("\n")
42
42
  end
43
43
  end
@@ -73,9 +73,9 @@ module Blazer::Ai
73
73
  # Timeout to prevent hung requests if LLM provider is slow/unresponsive
74
74
  Timeout.timeout(30, GenerationError, "SQL generation timed out. Please try again.") do
75
75
  RubyLLM.chat(model: model)
76
- .with_temperature(temperature)
77
- .ask(prompt)
78
- .content
76
+ .with_temperature(temperature)
77
+ .ask(prompt)
78
+ .content
79
79
  end
80
80
  end
81
81
 
@@ -8,7 +8,12 @@ module Blazer::Ai::UrlHelper
8
8
  path = "/ai/generate_sql"
9
9
 
10
10
  if defined?(Rails.application)
11
- main_route = Rails.application.routes.routes.find { |r| r.name == "blazer" }
11
+ # Find the route that mounts Blazer::Engine
12
+ # The route name could be 'blazer', 'admin_blazer', etc.
13
+ main_route = Rails.application.routes.routes.find do |r|
14
+ r.app.respond_to?(:app) && r.app.app == Blazer::Engine
15
+ end
16
+
12
17
  if main_route
13
18
  blazer_mount = main_route.path.spec.to_s.gsub("(.:format)", "")
14
19
  path = blazer_mount + path
@@ -1,5 +1,5 @@
1
1
  module Blazer
2
2
  module Ai
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
@@ -5,9 +5,26 @@ module BlazerAi
5
5
  class InstallGenerator < Rails::Generators::Base
6
6
  source_root File.expand_path("templates", __dir__)
7
7
 
8
- def copy_initializer
8
+ def copy_blazer_ai_initializer
9
9
  copy_file "blazer_ai.rb", "config/initializers/blazer_ai.rb"
10
10
  end
11
+
12
+ def copy_ruby_llm_initializer
13
+ return if ruby_llm_configured?
14
+
15
+ copy_file "ruby_llm.rb", "config/initializers/ruby_llm.rb"
16
+ end
17
+
18
+ private
19
+
20
+ def ruby_llm_configured?
21
+ initializers_path = File.join(destination_root, "config/initializers")
22
+ return false unless File.directory?(initializers_path)
23
+
24
+ Dir.glob(File.join(initializers_path, "*.rb")).any? do |file|
25
+ File.read(file).include?("RubyLLM.configure")
26
+ end
27
+ end
11
28
  end
12
29
  end
13
30
  end
@@ -1,7 +1,3 @@
1
- RubyLLM.configure do |config|
2
- config.openai_api_key = ENV["OPENAI_API_KEY"]
3
- end
4
-
5
1
  Blazer::Ai.configure do |config|
6
2
  config.default_model = "gpt-5.1-codex"
7
3
  end
@@ -0,0 +1,3 @@
1
+ RubyLLM.configure do |config|
2
+ config.openai_api_key = ENV["OPENAI_API_KEY"]
3
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blazer-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kieran Klaassen
@@ -74,6 +74,7 @@ files:
74
74
  - lib/blazer/ai.rb
75
75
  - lib/blazer/ai/configuration.rb
76
76
  - lib/blazer/ai/engine.rb
77
+ - lib/blazer/ai/middleware.rb
77
78
  - lib/blazer/ai/prompt_sanitizer.rb
78
79
  - lib/blazer/ai/railtie.rb
79
80
  - lib/blazer/ai/rate_limiter.rb
@@ -85,6 +86,7 @@ files:
85
86
  - lib/generators/blazer_ai/USAGE
86
87
  - lib/generators/blazer_ai/install_generator.rb
87
88
  - lib/generators/blazer_ai/templates/blazer_ai.rb
89
+ - lib/generators/blazer_ai/templates/ruby_llm.rb
88
90
  homepage: https://github.com/kieranklaassen/blazer-ai
89
91
  licenses:
90
92
  - MIT