AIFaker 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7bde2c3f571fa5b745523114e1313f79ea768b1c48408918cbe9a1f2975e9c2a
4
- data.tar.gz: 1fe46a60220f6fb95ef4308db036b317a8e17ac7508c4af8b72db0f480ee9b2c
3
+ metadata.gz: ddbf0a8c9db82f66bf23693b731ccebf9998277c13f290257d50ce6b67042fa9
4
+ data.tar.gz: fd3219303d5164f2df18255fbd8bb6c3820cbaf17c48c5a94bd8361ab8e78dcb
5
5
  SHA512:
6
- metadata.gz: bf33a25da19a490ef4337bb59794c59919b380ccf76138617c42015de4f0243b56cf0d54fd6958c21db02721930674f0f7575e20d2b38e4a7d9032a9e9a88e78
7
- data.tar.gz: 54ad8a9d77abd245309fd8c0fa0f1b8791b2e46b28ff2a8c4913a0fc4959a675aac2d277d5b4645069641f84804a6ad7e55fa2bdbd1b56bcf2eea754d15eee7a
6
+ metadata.gz: cca0aaba360d89afd686dfc4b46e08ad32aa8c737ab14589ee585c7b8813dff0bac9755625baea8b1628bda2e5cbb341f479ae40cc04c34914ff99d2a4d59c84
7
+ data.tar.gz: a2ef54fd5527801185e47f265d0ba75f2035ebbeeb13fe1a8ebe1a4ba02defa97d8c3334345d4e4b0cd302cf0da8097c15b93a252ad2f8a11327a23de9994572
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "securerandom"
5
+
6
+ require_relative "errors"
7
+ require_relative "ui"
8
+ require_relative "schema_introspector"
9
+ require_relative "model_introspector"
10
+ require_relative "seed_planner"
11
+ require_relative "seed_runner"
12
+ require_relative "llm_adapters/ruby_llm"
13
+
14
+ module AIFaker
15
+ class Client
16
+ def self.connect(provider = nil, **options)
17
+ provider = normalize_provider(provider || ENV["AIFAKER_PROVIDER"] || "openai")
18
+
19
+ ui = options.delete(:ui) || UI.new
20
+
21
+ unless LLMAdapters::RubyLLM.available?
22
+ raise ConnectionError, 'Missing dependency: add `gem "ruby_llm"` to your Gemfile.'
23
+ end
24
+
25
+ adapter = LLMAdapters::RubyLLM.new(provider:, ui:, **options)
26
+
27
+ new(adapter:, ui:)
28
+ end
29
+
30
+ def self.normalize_provider(raw)
31
+ value = raw.to_s.strip.downcase
32
+ value = "anthropic" if value == "claude"
33
+ value
34
+ end
35
+
36
+ def initialize(adapter:, ui: UI.new)
37
+ @adapter = adapter
38
+ @ui = ui
39
+ @schema = nil
40
+ @models = nil
41
+ @associations = nil
42
+ @plan = nil
43
+ @automode = false
44
+ end
45
+
46
+ attr_reader :automode
47
+
48
+ def automode=(value)
49
+ @automode = !!value
50
+ end
51
+
52
+ # Enables/disables non-interactive mode.
53
+ # When enabled, AIFaker assumes "yes" for all prompts and picks a random count 10..20.
54
+ def automode!(value = true)
55
+ self.automode = value
56
+ self
57
+ end
58
+
59
+ def connected?
60
+ @adapter.connected?
61
+ end
62
+
63
+ def connect!
64
+ @adapter.connect!
65
+ @ui.banner("AIFaker successfully connected")
66
+ self
67
+ end
68
+
69
+ def read_schema
70
+ @schema = SchemaIntrospector.dump_schema
71
+ @adapter.share_schema!(@schema)
72
+ @ui.section("Schema shared")
73
+ @schema
74
+ end
75
+ alias_method :read_db_schema, :read_schema
76
+
77
+ def read_model_associations
78
+ data = ModelIntrospector.describe_models_and_associations
79
+ @models = data.fetch(:models)
80
+ @associations = data.fetch(:associations)
81
+ @adapter.share_models!(@models, @associations)
82
+ @ui.section("Model associations shared")
83
+ data
84
+ end
85
+ alias_method :read_associations, :read_model_associations
86
+
87
+ def plan_seed
88
+ ensure_schema_and_models!
89
+ @plan = SeedPlanner.new(models: @models, associations: @associations).plan
90
+ @adapter.share_seed_plan!(@plan)
91
+ @plan
92
+ end
93
+ alias_method :seed_plan, :plan_seed
94
+
95
+ def seed!
96
+ connect! unless connected?
97
+ read_schema if @schema.nil?
98
+ read_model_associations if @models.nil?
99
+ plan_seed if @plan.nil?
100
+
101
+ proceed =
102
+ if automode
103
+ true
104
+ else
105
+ @ui.ask_yes_no("Do you want to proceed with creation of dummy realistic data?")
106
+ end
107
+ return :skipped unless proceed
108
+
109
+ count =
110
+ if automode
111
+ rand(10..20)
112
+ else
113
+ @ui.ask_integer("How much count you needed approx for creation dummy data in each tables?", min: 5, max: 50)
114
+ end
115
+
116
+ SeedRunner.new(
117
+ ui: @ui,
118
+ adapter: @adapter,
119
+ plan: @plan,
120
+ default_count: count
121
+ ).run(assume_yes: automode)
122
+ end
123
+ alias_method :run!, :seed!
124
+
125
+ private
126
+
127
+ def ensure_schema_and_models!
128
+ read_schema if @schema.nil?
129
+ read_model_associations if @models.nil?
130
+ end
131
+ end
132
+ end
133
+
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIFaker
4
+ class Error < StandardError; end
5
+ class ConnectionError < Error; end
6
+ class MissingRailsError < Error; end
7
+ class UnsupportedProviderError < Error; end
8
+ end
9
+
@@ -0,0 +1,482 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIFaker
4
+ module LLMAdapters
5
+ class RubyLLM
6
+ def self.available?
7
+ require "ruby_llm"
8
+ true
9
+ rescue LoadError
10
+ false
11
+ end
12
+
13
+ def initialize(provider:, ui: nil, **options)
14
+ @provider = provider
15
+ @ui = ui
16
+ @options = options
17
+ @connected = false
18
+ @schema = nil
19
+ @models = nil
20
+ @associations = nil
21
+ @plan = nil
22
+ @generated_uniques = Hash.new { |h, k| h[k] = Hash.new { |hh, kk| hh[kk] = [] } }
23
+ @failure_feedback = {}
24
+ end
25
+
26
+ def connected?
27
+ @connected
28
+ end
29
+
30
+ def connect!
31
+ self.class.available? or raise ConnectionError, "Missing dependency: add `gem \"ruby_llm\"` to your Gemfile."
32
+
33
+ @provider_sym = @provider.to_sym
34
+ configure_ruby_llm!
35
+ @connected = true
36
+ rescue StandardError => e
37
+ raise ConnectionError, "Failed to connect via ruby_llm (provider=#{@provider}): #{e.class}: #{e.message}"
38
+ end
39
+
40
+ def share_schema!(schema)
41
+ @schema = schema
42
+ end
43
+
44
+ def share_models!(models, associations)
45
+ @models = models
46
+ @associations = associations
47
+ end
48
+
49
+ def share_seed_plan!(plan)
50
+ @plan = plan
51
+ end
52
+
53
+ def generate_attributes(model)
54
+ prompt = build_prompt_for(model)
55
+ response_text = chat_with_indicator(prompt, label: "Searching #{model.name}")
56
+ parsed = parse_json_hash(response_text)
57
+ attrs = coerce_attributes(model, parsed)
58
+ remember_generated_unique_values(model, attrs)
59
+ attrs
60
+ rescue StandardError => e
61
+ raise Error, "LLM attribute generation failed for #{model.name}: #{e.class}: #{e.message}"
62
+ end
63
+
64
+ def suggest_fix(model, error:, last_attributes:)
65
+ prompt = build_fix_prompt_for(model, error:, last_attributes:)
66
+ chat_with_indicator(prompt, label: "Searching fix for #{model.name}")
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
71
+ def register_failure(model, error:, attempted_attributes:)
72
+ taken_by_column = uniqueness_taken_values(model, error, attempted_attributes)
73
+ @failure_feedback[model.name] = {
74
+ message: "#{error.class}: #{error.message}",
75
+ details: validation_error_details(error),
76
+ attempted_attributes: attempted_attributes,
77
+ dont_suggest: taken_by_column
78
+ }
79
+ rescue StandardError
80
+ nil
81
+ end
82
+
83
+ def apply_fix!(_fix)
84
+ # The fix is typically guidance; in v0 we just retry with fresh attributes.
85
+ end
86
+
87
+ private
88
+
89
+ def build_prompt_for(model)
90
+ {
91
+ role: "system",
92
+ content: <<~TEXT
93
+ You are generating realistic seed data for a Rails app.
94
+
95
+ Return ONLY a JSON object of attributes for the ActiveRecord model "#{model.name}".
96
+ Rules:
97
+ - Do NOT include id, created_at, updated_at
98
+ - For belongs_to associations, include the foreign key field (e.g. user_id) as an integer id that exists.
99
+ - Use the schema + model context provided below.
100
+
101
+ SCHEMA:
102
+ #{@schema}
103
+
104
+ MODELS:
105
+ #{@models}
106
+
107
+ ASSOCIATIONS:
108
+ #{@associations}
109
+
110
+ SEED PLAN:
111
+ #{@plan}
112
+
113
+ UNIQUENESS CONSTRAINTS FOR #{model.name}:
114
+ #{uniqueness_constraints_text(model)}
115
+
116
+ VALIDATIONS FOR #{model.name}:
117
+ #{model_validations_text(model)}
118
+
119
+ LAST FAILURE FOR #{model.name} (if any):
120
+ #{failure_feedback_text(model)}
121
+
122
+ DO NOT SUGGEST THESE VALUES FOR #{model.name}:
123
+ #{dont_suggest_text(model)}
124
+
125
+ Important:
126
+ - If a previous attempt failed due to uniqueness, generate NEW values for those fields.
127
+ - Never reuse exact values shown in attempted_attributes/recent_generated/existing_sample/dont_suggest for unique columns.
128
+ TEXT
129
+ }
130
+ end
131
+
132
+ def build_fix_prompt_for(model, error:, last_attributes:)
133
+ require "json"
134
+ {
135
+ role: "system",
136
+ content: <<~TEXT
137
+ We failed to create a #{model.name} record.
138
+ Error: #{error.class}: #{error.message}
139
+ Validation details: #{validation_error_details(error)}
140
+ Last attributes (JSON): #{JSON.generate(last_attributes)}
141
+
142
+ Uniqueness constraints:
143
+ #{uniqueness_constraints_text(model)}
144
+
145
+ Validations:
146
+ #{model_validations_text(model)}
147
+
148
+ Do not suggest:
149
+ #{dont_suggest_text(model)}
150
+
151
+ Provide a corrected JSON object of attributes ONLY.
152
+ TEXT
153
+ }
154
+ end
155
+
156
+ def parse_json_hash(text)
157
+ require "json"
158
+ JSON.parse(text)
159
+ end
160
+
161
+ def coerce_attributes(model, attrs)
162
+ attrs = attrs.transform_keys(&:to_s)
163
+ allowed = model.columns.map(&:name) - [model.primary_key, "created_at", "updated_at"]
164
+ attrs.slice(*allowed)
165
+ end
166
+
167
+ def chat_with_indicator(prompt, label:)
168
+ runner = lambda do
169
+ last_error = nil
170
+
171
+ candidate_chat_models.each do |model_id|
172
+ begin
173
+ chat = ::RubyLLM.chat(model: model_id, provider: @provider_sym, **@options)
174
+ chat.with_instructions(prompt.fetch(:content), replace: false)
175
+ response = chat.ask("Return only the JSON object.")
176
+ return message_to_text(response)
177
+ rescue StandardError => e
178
+ last_error = e
179
+ next if model_not_supported_error?(e)
180
+ raise
181
+ end
182
+ end
183
+
184
+ raise(last_error || "LLM call failed")
185
+ end
186
+
187
+ if @ui && @ui.respond_to?(:with_searching)
188
+ @ui.with_searching(label) { runner.call }
189
+ else
190
+ runner.call
191
+ end
192
+ end
193
+
194
+ def message_to_text(message)
195
+ content = message.respond_to?(:content) ? message.content : message
196
+ return content if content.is_a?(String)
197
+ return "" if content.nil?
198
+
199
+ if content.is_a?(Array)
200
+ content.filter_map { |p| p.is_a?(Hash) ? p["text"] || p[:text] : nil }.join
201
+ else
202
+ content.to_s
203
+ end
204
+ end
205
+
206
+ def configure_ruby_llm!
207
+ ::RubyLLM.configure do |config|
208
+ # Provider credentials (prefer existing config; otherwise env).
209
+ case @provider_sym
210
+ when :openai
211
+ config.openai_api_key ||= ENV["OPENAI_API_KEY"]
212
+ when :anthropic, :claude
213
+ config.anthropic_api_key ||= ENV["ANTHROPIC_API_KEY"]
214
+ when :gemini
215
+ config.gemini_api_key ||= ENV["GEMINI_API_KEY"]
216
+ when :deepseek
217
+ config.deepseek_api_key ||= ENV["DEEPSEEK_API_KEY"]
218
+ when :bedrock
219
+ config.bedrock_api_key ||= ENV["BEDROCK_API_KEY"]
220
+ config.bedrock_secret_key ||= ENV["BEDROCK_SECRET_KEY"]
221
+ config.bedrock_region ||= ENV["BEDROCK_REGION"]
222
+ config.bedrock_session_token ||= ENV["BEDROCK_SESSION_TOKEN"]
223
+ end
224
+
225
+ # Optional global tuning
226
+ config.default_model ||= ENV["AIFAKER_MODEL"] if ENV["AIFAKER_MODEL"]
227
+ config.request_timeout ||= ENV["AIFAKER_TIMEOUT"].to_i if ENV["AIFAKER_TIMEOUT"]
228
+ end
229
+
230
+ # If the configured default model doesn't exist for this provider, pick one dynamically.
231
+ begin
232
+ current = ::RubyLLM.config.default_model
233
+ ::RubyLLM.models.find(current, @provider_sym)
234
+ rescue StandardError
235
+ picked = provider_default_chat_model
236
+ ::RubyLLM.configure { |c| c.default_model = picked } if picked
237
+ end
238
+
239
+ # Fail fast if still not configured (ruby_llm will also raise later).
240
+ provider = ::RubyLLM::Provider.providers[@provider_sym]
241
+ return if provider && provider.configured?
242
+
243
+ missing =
244
+ case @provider_sym
245
+ when :openai then "OPENAI_API_KEY"
246
+ when :anthropic, :claude then "ANTHROPIC_API_KEY"
247
+ when :gemini then "GEMINI_API_KEY"
248
+ when :deepseek then "DEEPSEEK_API_KEY"
249
+ when :bedrock then "BEDROCK_API_KEY/BEDROCK_SECRET_KEY/BEDROCK_REGION"
250
+ else "provider credentials"
251
+ end
252
+
253
+ raise ConnectionError, "#{@provider_sym} is not configured. Set #{missing} (or configure RubyLLM in an initializer)."
254
+ end
255
+
256
+ def provider_default_chat_model
257
+ provider_chat_model_ids.first
258
+ rescue StandardError
259
+ nil
260
+ end
261
+
262
+ def candidate_chat_models
263
+ # 1) explicit override always wins
264
+ explicit = ENV["AIFAKER_MODEL"]&.strip
265
+ return [explicit] if explicit && !explicit.empty?
266
+
267
+ # 2) RubyLLM global default, if set, then try all provider chat models
268
+ begin
269
+ current = ::RubyLLM.config.default_model
270
+ if current && !current.to_s.strip.empty?
271
+ return [current.to_s.strip, *provider_chat_model_ids].compact.uniq
272
+ end
273
+ rescue StandardError
274
+ # ignore and fall back to provider models
275
+ end
276
+
277
+ # 3) provider chat models
278
+ provider_chat_model_ids
279
+ end
280
+
281
+ def provider_chat_model_ids
282
+ models = ::RubyLLM.models.by_provider(@provider_sym).chat_models.all
283
+ ids = models.map(&:id).compact.map(&:to_s).map(&:strip).reject(&:empty?)
284
+
285
+ # RubyLLM may surface Gemini models that the API rejects (e.g. `models/aqa`).
286
+ # We'll both filter obvious mismatches here and also rely on runtime fallback.
287
+ ids = ids.reject { |id| id.match?(/\/aqa\b/i) } if @provider_sym == :gemini
288
+
289
+ ids.uniq
290
+ rescue StandardError
291
+ []
292
+ end
293
+
294
+ def model_not_supported_error?(error)
295
+ msg = error.message.to_s
296
+ return false if msg.empty?
297
+
298
+ msg.include?("is not found for API version") ||
299
+ msg.include?("is not supported for generateContent") ||
300
+ msg.match?(/models\/aqa/i)
301
+ end
302
+
303
+ def uniqueness_constraints_text(model)
304
+ constraints = unique_columns_for(model)
305
+ return "none" if constraints.empty?
306
+
307
+ constraints.map do |col|
308
+ recent = @generated_uniques.dig(model.name, col)&.last(8) || []
309
+ existing = safe_existing_values(model, col, limit: 8)
310
+ <<~ROW.chomp
311
+ - #{col}: must be unique
312
+ recent_generated: #{recent.empty? ? "[]" : recent.inspect}
313
+ existing_sample: #{existing.empty? ? "[]" : existing.inspect}
314
+ ROW
315
+ end.join("\n")
316
+ rescue StandardError
317
+ "none"
318
+ end
319
+
320
+ def unique_columns_for(model)
321
+ columns = []
322
+
323
+ begin
324
+ idx_cols =
325
+ model.connection.indexes(model.table_name)
326
+ .select(&:unique)
327
+ .select { |i| i.columns.size == 1 }
328
+ .map { |i| i.columns.first.to_s }
329
+ columns.concat(idx_cols)
330
+ rescue StandardError
331
+ # ignore
332
+ end
333
+
334
+ begin
335
+ validator_cols =
336
+ model.validators
337
+ .select { |v| v.kind == :uniqueness }
338
+ .flat_map { |v| v.attributes.map(&:to_s) }
339
+ columns.concat(validator_cols)
340
+ rescue StandardError
341
+ # ignore
342
+ end
343
+
344
+ columns.uniq
345
+ end
346
+
347
+ def remember_generated_unique_values(model, attrs)
348
+ return unless attrs.is_a?(Hash)
349
+
350
+ unique_columns_for(model).each do |col|
351
+ value = attrs[col] || attrs[col.to_sym]
352
+ next if value.nil?
353
+
354
+ bucket = @generated_uniques[model.name][col]
355
+ bucket << value
356
+ bucket.shift while bucket.length > 30
357
+ end
358
+ rescue StandardError
359
+ nil
360
+ end
361
+
362
+ def safe_existing_values(model, column, limit:)
363
+ model.where.not(column => nil).limit(limit).pluck(column).compact
364
+ rescue StandardError
365
+ []
366
+ end
367
+
368
+ def failure_feedback_text(model)
369
+ data = @failure_feedback[model.name]
370
+ return "none" unless data
371
+
372
+ attrs_json =
373
+ begin
374
+ require "json"
375
+ JSON.generate(data[:attempted_attributes] || {})
376
+ rescue StandardError
377
+ (data[:attempted_attributes] || {}).to_s
378
+ end
379
+
380
+ <<~TEXT.chomp
381
+ message: #{data[:message]}
382
+ details: #{data[:details]}
383
+ attempted_attributes: #{attrs_json}
384
+ dont_suggest: #{data[:dont_suggest].to_s}
385
+ TEXT
386
+ end
387
+
388
+ def dont_suggest_text(model)
389
+ data = @failure_feedback[model.name]
390
+ suggestions = data && data[:dont_suggest].is_a?(Hash) ? data[:dont_suggest] : {}
391
+ return "none" if suggestions.empty?
392
+
393
+ suggestions.map do |column, values|
394
+ clean = Array(values).compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
395
+ "- #{column}: #{clean.first(25).inspect}"
396
+ end.join("\n")
397
+ rescue StandardError
398
+ "none"
399
+ end
400
+
401
+ def validation_error_details(error)
402
+ return "none" unless defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
403
+
404
+ record = error.respond_to?(:record) ? error.record : nil
405
+ return "none" unless record && record.respond_to?(:errors)
406
+
407
+ details = record.errors.details
408
+ return "none" unless details.is_a?(Hash)
409
+
410
+ details.transform_values do |v|
411
+ Array(v).map { |d| d.is_a?(Hash) ? d[:error] : d }.compact
412
+ end.to_s
413
+ rescue StandardError
414
+ "none"
415
+ end
416
+
417
+ def model_validations_text(model)
418
+ validators = model.validators
419
+ return "none" if validators.nil? || validators.empty?
420
+
421
+ validators.map do |validator|
422
+ attributes = validator.attributes.map(&:to_s)
423
+ kind = validator.kind.to_s
424
+ options = validator.options.dup
425
+ options.delete(:class)
426
+ options_text = options.empty? ? "{}" : serialize_validator_options(options)
427
+
428
+ "- #{kind} on #{attributes.join(', ')} options=#{options_text}"
429
+ end.join("\n")
430
+ rescue StandardError
431
+ "none"
432
+ end
433
+
434
+ def uniqueness_taken_values(model, error, attempted_attributes)
435
+ cols = uniqueness_error_columns(error)
436
+ return {} if cols.empty?
437
+
438
+ attrs = attempted_attributes.is_a?(Hash) ? attempted_attributes.transform_keys(&:to_s) : {}
439
+ cols.each_with_object({}) do |col, out|
440
+ values = []
441
+ values.concat(safe_existing_values(model, col, limit: 30))
442
+ attempted = attrs[col]
443
+ values << attempted unless attempted.nil?
444
+ values.concat(@generated_uniques.dig(model.name, col) || [])
445
+ out[col] = values.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq.first(30)
446
+ end
447
+ rescue StandardError
448
+ {}
449
+ end
450
+
451
+ def uniqueness_error_columns(error)
452
+ return [] unless defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
453
+
454
+ record = error.respond_to?(:record) ? error.record : nil
455
+ return [] unless record && record.respond_to?(:errors) && record.errors.respond_to?(:details)
456
+
457
+ record.errors.details.each_with_object([]) do |(attr, details), out|
458
+ next unless Array(details).any? { |d| d.is_a?(Hash) && d[:error] == :taken }
459
+ out << attr.to_s
460
+ end
461
+ rescue StandardError
462
+ []
463
+ end
464
+
465
+ def serialize_validator_options(options)
466
+ sanitized = options.transform_values do |v|
467
+ case v
468
+ when Regexp then v.inspect
469
+ when Proc then "proc"
470
+ else v
471
+ end
472
+ end
473
+
474
+ require "json"
475
+ JSON.generate(sanitized)
476
+ rescue StandardError
477
+ options.to_s
478
+ end
479
+ end
480
+ end
481
+ end
482
+
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIFaker
4
+ module ModelIntrospector
5
+ module_function
6
+
7
+ def describe_models_and_associations
8
+ ensure_active_record!
9
+
10
+ eager_load_models!
11
+ models = app_models
12
+ associations = models.to_h { |m| [m.name, describe_associations(m)] }
13
+
14
+ {
15
+ models: models.map { |m| describe_model(m) },
16
+ associations:
17
+ }
18
+ end
19
+
20
+ def ensure_active_record!
21
+ return if defined?(ActiveRecord::Base)
22
+
23
+ raise MissingRailsError, "ActiveRecord is not loaded. Run AIFaker from a Rails app."
24
+ end
25
+
26
+ def eager_load_models!
27
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
28
+
29
+ # In development, Rails often doesn't eager-load models, so descendants can be empty.
30
+ # We force-load model files so association introspection works during `db:seed`.
31
+ Rails.application.eager_load!
32
+
33
+ models_dir = Rails.root.join("app/models")
34
+ loader =
35
+ if defined?(ActiveSupport::Dependencies) && ActiveSupport::Dependencies.respond_to?(:require_dependency)
36
+ ActiveSupport::Dependencies.method(:require_dependency)
37
+ else
38
+ method(:require)
39
+ end
40
+
41
+ Dir.glob(models_dir.join("**/*.rb")).sort.each { |f| loader.call(f) }
42
+ rescue LoadError, StandardError
43
+ # best-effort; not fatal (but if this fails, seeding may not see all models)
44
+ end
45
+
46
+ def app_models
47
+ base =
48
+ if defined?(ApplicationRecord)
49
+ ApplicationRecord
50
+ else
51
+ ActiveRecord::Base
52
+ end
53
+
54
+ base.descendants
55
+ .reject(&:abstract_class?)
56
+ .reject { |m| m.name.nil? || m.name.empty? }
57
+ .sort_by(&:name)
58
+ end
59
+
60
+ def describe_model(model)
61
+ {
62
+ name: model.name,
63
+ table_name: model.table_name,
64
+ primary_key: model.primary_key,
65
+ columns: model.columns.map do |c|
66
+ {
67
+ name: c.name,
68
+ type: c.type.to_s,
69
+ null: c.null,
70
+ default: c.default
71
+ }
72
+ end
73
+ }
74
+ end
75
+
76
+ def describe_associations(model)
77
+ model.reflections.values.map do |r|
78
+ {
79
+ name: r.name.to_s,
80
+ macro: r.macro.to_s,
81
+ class_name: r.class_name.to_s,
82
+ foreign_key: r.foreign_key.to_s,
83
+ optional: (r.respond_to?(:options) ? !!r.options[:optional] : false)
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module AIFaker
6
+ module SchemaIntrospector
7
+ module_function
8
+
9
+ def dump_schema
10
+ ensure_active_record!
11
+
12
+ conn = ActiveRecord::Base.connection
13
+ io = StringIO.new
14
+ ActiveRecord::SchemaDumper.dump(conn, io)
15
+ io.string
16
+ end
17
+
18
+ def ensure_active_record!
19
+ return if defined?(ActiveRecord::Base)
20
+
21
+ raise MissingRailsError, "ActiveRecord is not loaded. Run AIFaker from a Rails app (or require ActiveRecord)."
22
+ end
23
+ end
24
+ end
25
+
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIFaker
4
+ class SeedPlanner
5
+ def initialize(models:, associations:)
6
+ @models = models
7
+ @associations = associations
8
+ end
9
+
10
+ def plan
11
+ graph = build_dependency_graph
12
+ order = topo_sort(graph)
13
+
14
+ {
15
+ order:,
16
+ graph:
17
+ }
18
+ end
19
+
20
+ private
21
+
22
+ def build_dependency_graph
23
+ # edge: model -> depends_on_model
24
+ by_name = @models.to_h { |m| [m.fetch(:name), m] }
25
+
26
+ graph = Hash.new { |h, k| h[k] = [] }
27
+ by_name.each_key { |name| graph[name] ||= [] }
28
+
29
+ @associations.each do |model_name, refs|
30
+ deps =
31
+ refs
32
+ .select { |r| r[:macro] == "belongs_to" }
33
+ .map { |r| r[:class_name] }
34
+ .select { |klass| by_name.key?(klass) }
35
+
36
+ graph[model_name].concat(deps).uniq!
37
+ end
38
+
39
+ graph
40
+ end
41
+
42
+ def topo_sort(graph)
43
+ # `graph` maps: node -> dependencies (deps must come before node)
44
+ deps = graph.transform_values(&:dup)
45
+ order = []
46
+
47
+ loop do
48
+ ready = deps.select { |_, v| v.empty? }.keys.sort
49
+ break if ready.empty?
50
+
51
+ ready.each do |n|
52
+ order << n
53
+ deps.delete(n)
54
+ end
55
+
56
+ deps.each_value { |v| v.reject! { |d| order.include?(d) } }
57
+ end
58
+
59
+ # If there is a cycle (or missing nodes), append remaining in stable order.
60
+ order.concat(deps.keys.sort)
61
+ end
62
+ end
63
+ end
64
+
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIFaker
4
+ class SeedRunner
5
+ def initialize(ui:, adapter:, plan:, default_count:)
6
+ @ui = ui
7
+ @adapter = adapter
8
+ @plan = plan
9
+ @default_count = default_count
10
+ @created = {}
11
+ @seeding_stack = {}
12
+ end
13
+
14
+ def run(assume_yes: false)
15
+ order = @plan.fetch(:order)
16
+
17
+ if order.empty?
18
+ @ui.info("No seedable models found. Create some models/migrations first, then re-run `rails db:seed`.")
19
+ return :noop
20
+ end
21
+
22
+ order.each do |model_name|
23
+ next unless constantize(model_name)
24
+
25
+ should_seed =
26
+ if assume_yes
27
+ true
28
+ else
29
+ @ui.ask_yes_no("Do you want to feed #{model_name} table data?")
30
+ end
31
+ next unless should_seed
32
+
33
+ seed_model(model_name, @default_count)
34
+ end
35
+
36
+ print_summary(order)
37
+ :ok
38
+ end
39
+
40
+ private
41
+
42
+ def seed_model(model_name, count)
43
+ model = constantize(model_name)
44
+ return unless model
45
+
46
+ return if @seeding_stack[model_name]
47
+ @seeding_stack[model_name] = true
48
+
49
+ ensure_required_parents!(model)
50
+ @ui.table_start(model_name)
51
+
52
+ idx = 0
53
+ attempts = 0
54
+
55
+ while idx < count
56
+ attempts += 1
57
+ begin
58
+ attrs = @adapter.generate_attributes(model)
59
+ record = model.create!(attrs)
60
+ @created[model_name] ||= []
61
+ @created[model_name] << record
62
+ idx += 1
63
+ @ui.record_created(idx, model_name, record.id)
64
+ rescue StandardError => e
65
+ @ui.error("#{model_name} create failed: #{e.class}: #{e.message}")
66
+ @adapter.register_failure(model, error: e, attempted_attributes: attrs) if @adapter.respond_to?(:register_failure)
67
+
68
+ # Auto-repair common relational validations before asking the adapter.
69
+ repair_notes = []
70
+ repaired =
71
+ if @ui.respond_to?(:with_auto_repair)
72
+ @ui.with_auto_repair("Auto-repairing #{model_name}") { auto_repair!(model, attrs, e, notes: repair_notes) }
73
+ else
74
+ auto_repair!(model, attrs, e, notes: repair_notes)
75
+ end
76
+
77
+ repair_notes.each do |note|
78
+ if @ui.respond_to?(:auto_repair)
79
+ @ui.auto_repair(note)
80
+ else
81
+ @ui.info(note)
82
+ end
83
+ end
84
+
85
+ if repaired
86
+ # retry quickly after repair
87
+ next
88
+ end
89
+
90
+ fix = @adapter.suggest_fix(model, error: e, last_attributes: attrs)
91
+ @adapter.apply_fix!(fix) if fix
92
+ raise e if attempts > (count * 10)
93
+ end
94
+ end
95
+ ensure
96
+ @seeding_stack.delete(model_name)
97
+ end
98
+
99
+ def auto_repair!(model, attrs, error, notes:)
100
+ return false unless defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
101
+
102
+ record = error.respond_to?(:record) ? error.record : nil
103
+ return false unless record
104
+
105
+ repaired = false
106
+
107
+ # A) If any required parent table is empty, seed it.
108
+ record.class.reflections.values.select { |r| r.macro == :belongs_to }.each do |r|
109
+ assoc_class = constantize(r.class_name)
110
+ next unless assoc_class
111
+ next if assoc_class.count.positive?
112
+
113
+ notes << "Seeding missing parent #{assoc_class.name} (needed for #{record.class.name})"
114
+ seed_model(assoc_class.name, [@default_count, 5].min)
115
+ repaired = true
116
+ end
117
+
118
+ # B) If we reference multiple parents, ensure join rows exist for fk pairs.
119
+ repaired ||= ensure_join_rows_exist!(record, attrs, notes:)
120
+ repaired ||= repair_uniqueness!(record, attrs, notes:)
121
+
122
+ repaired
123
+ rescue StandardError => e
124
+ @ui.error("Auto-repair failed: #{e.class}: #{e.message}")
125
+ false
126
+ end
127
+
128
+ def ensure_join_rows_exist!(record, attrs, notes:)
129
+ return false unless attrs.is_a?(Hash)
130
+
131
+ belongs_to_refs = record.class.reflections.values.select { |r| r.macro == :belongs_to }
132
+ return false if belongs_to_refs.size < 2
133
+
134
+ fk_values = belongs_to_refs.to_h do |r|
135
+ fk = r.foreign_key.to_s
136
+ value = attrs[fk] || attrs[fk.to_sym]
137
+ [fk, value]
138
+ end
139
+ fk_values.compact!
140
+
141
+ # Only attempt join repair when we have at least 2 fk values present.
142
+ present = fk_values.select { |_, v| !v.nil? }
143
+ return false if present.size < 2
144
+
145
+ repaired_any = false
146
+ present.keys.combination(2).each do |fk_a, fk_b|
147
+ id_a = fk_values[fk_a]
148
+ id_b = fk_values[fk_b]
149
+ next if id_a.nil? || id_b.nil?
150
+
151
+ join_models_for(fk_a, fk_b).each do |join_model|
152
+ begin
153
+ join_model.find_or_create_by!(fk_a => id_a, fk_b => id_b)
154
+ notes << "Ensured #{join_model.name}(#{fk_a}=#{id_a}, #{fk_b}=#{id_b}) exists"
155
+ repaired_any = true
156
+ rescue StandardError
157
+ # ignore and try other join models / pairs
158
+ end
159
+ end
160
+ end
161
+
162
+ repaired_any
163
+ end
164
+
165
+ def repair_uniqueness!(record, attrs, notes:)
166
+ return false unless attrs.is_a?(Hash)
167
+ return false unless record.respond_to?(:errors) && record.errors.respond_to?(:details)
168
+
169
+ taken_fields =
170
+ record.errors.details.each_with_object([]) do |(attr, details), out|
171
+ next unless details.is_a?(Array)
172
+ out << attr.to_s if details.any? { |d| d.is_a?(Hash) && d[:error] == :taken }
173
+ end
174
+
175
+ return false if taken_fields.empty?
176
+
177
+ mutated = attrs.transform_keys(&:to_s).dup
178
+ changed = false
179
+
180
+ taken_fields.each do |field|
181
+ original = mutated[field]
182
+ next if original.nil?
183
+
184
+ unique_value = build_unique_value(original, field)
185
+ next if unique_value == original
186
+
187
+ mutated[field] = unique_value
188
+ changed = true
189
+ end
190
+
191
+ return false unless changed
192
+
193
+ fresh = record.class.create!(mutated)
194
+ @created[record.class.name] ||= []
195
+ @created[record.class.name] << fresh
196
+ notes << "Uniqueness conflict fixed for #{record.class.name} (created id: #{fresh.id})"
197
+ true
198
+ rescue StandardError
199
+ false
200
+ end
201
+
202
+ def build_unique_value(value, field)
203
+ suffix = Time.now.to_i.to_s(36) + rand(10_000).to_s(36)
204
+ return value unless value.is_a?(String)
205
+
206
+ if field.to_s.include?("domain")
207
+ if value.include?(".")
208
+ base, ext = value.split(".", 2)
209
+ "#{base}-#{suffix}.#{ext}"
210
+ else
211
+ "#{value}-#{suffix}"
212
+ end
213
+ elsif value.match?(/\A[^@\s]+@[^@\s]+\z/)
214
+ local, domain = value.split("@", 2)
215
+ "#{local}+#{suffix}@#{domain}"
216
+ else
217
+ "#{value} #{suffix}"
218
+ end
219
+ end
220
+
221
+ def join_models_for(fk_a, fk_b)
222
+ all_application_models.select do |m|
223
+ next false if m.abstract_class?
224
+ next false unless m.table_exists? rescue false
225
+
226
+ refs = m.reflections.values.select { |r| r.macro == :belongs_to }
227
+ fks = refs.map { |r| r.foreign_key.to_s }
228
+ (fks.include?(fk_a) && fks.include?(fk_b))
229
+ end
230
+ end
231
+
232
+ def all_application_models
233
+ base =
234
+ if defined?(ApplicationRecord)
235
+ ApplicationRecord
236
+ else
237
+ ActiveRecord::Base
238
+ end
239
+
240
+ base.descendants.reject(&:abstract_class?).reject { |m| m.name.nil? || m.name.empty? }
241
+ end
242
+
243
+ def ensure_required_parents!(model)
244
+ model.reflections.values
245
+ .select { |r| r.macro == :belongs_to }
246
+ .each do |r|
247
+ assoc_class = constantize(r.class_name)
248
+ next unless assoc_class
249
+
250
+ next if assoc_class.count.positive?
251
+
252
+ @ui.info("Auto-seeding required parent table #{assoc_class.name} (needed for #{model.name})")
253
+ seed_model(assoc_class.name, [@default_count, 5].min)
254
+ end
255
+ end
256
+
257
+ def constantize(name)
258
+ Object.const_get(name)
259
+ rescue NameError
260
+ nil
261
+ end
262
+
263
+ def print_summary(order)
264
+ rows =
265
+ order.filter_map do |model_name|
266
+ model = constantize(model_name)
267
+ next if model.nil?
268
+ next if model.respond_to?(:abstract_class?) && model.abstract_class?
269
+
270
+ table = (model.respond_to?(:table_name) ? model.table_name : model_name)
271
+ count = model.respond_to?(:count) ? model.count : 0
272
+
273
+ { model: model_name, table: table, count: count }
274
+ end
275
+
276
+ @ui.summary("✅ Seeding Summary", rows)
277
+ rescue StandardError => e
278
+ @ui.error("Summary failed: #{e.class}: #{e.message}")
279
+ end
280
+ end
281
+ end
data/lib/AIFaker/ui.rb ADDED
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIFaker
4
+ class UI
5
+ RESET = "\e[0m"
6
+ COLORS = {
7
+ primary: "\e[1;36m", # bold cyan
8
+ section: "\e[1;35m", # bold magenta
9
+ info: "\e[0;34m", # blue
10
+ success: "\e[0;32m", # green
11
+ warning: "\e[0;33m", # yellow
12
+ error: "\e[1;31m", # bold red
13
+ muted: "\e[0;90m" # gray
14
+ }.freeze
15
+
16
+ def initialize(io: $stdout, input: $stdin)
17
+ @io = io
18
+ @input = input
19
+ end
20
+
21
+ def banner(text)
22
+ @io.puts paint("✨ ##### #{text} #####", :primary)
23
+ end
24
+
25
+ def section(text)
26
+ @io.puts paint("🧭 ##====== #{text} =====##", :section)
27
+ end
28
+
29
+ def ask_yes_no(prompt)
30
+ loop do
31
+ @io.print paint("❓ #{prompt} y/n\n", :warning)
32
+ answer = @input.gets&.strip&.downcase
33
+ return true if answer == "y"
34
+ return false if answer == "n"
35
+ end
36
+ end
37
+
38
+ def ask_integer(prompt, min:, max:)
39
+ loop do
40
+ @io.print paint("🔢 #{prompt}\n", :warning)
41
+ raw = @input.gets&.strip
42
+ next if raw.nil? || raw.empty?
43
+ int = Integer(raw, exception: false)
44
+ next if int.nil?
45
+ next if int < min || int > max
46
+ return int
47
+ end
48
+ end
49
+
50
+ def info(text)
51
+ @io.puts paint("ℹ️ #{text}", :info)
52
+ end
53
+
54
+ def auto_repair(text)
55
+ @io.puts paint("🛠️ #{text}", :warning)
56
+ end
57
+
58
+ def table_start(name)
59
+ section("🏗️ #{name} Creating")
60
+ end
61
+
62
+ def record_created(idx, model_name, id)
63
+ @io.puts paint("✅ ====> #{idx}. #{model_name} created successfully with id: #{id}", :success)
64
+ end
65
+
66
+ def error(text)
67
+ @io.puts paint("❌ !! ERROR: #{text}", :error)
68
+ end
69
+
70
+ def summary(title, rows)
71
+ section("📊 #{title}")
72
+ @io.puts paint(render_table(rows), :muted)
73
+ end
74
+
75
+ # Shows a "Searching..." animation while the block runs.
76
+ # Only animates on TTY; otherwise prints a single line.
77
+ def with_searching(label = "Searching")
78
+ with_activity(
79
+ label,
80
+ emoji: "🔎",
81
+ color: :info,
82
+ interval: 0.2,
83
+ frames: [".", "..", "...", "....", ".....", "......"]
84
+ ) { yield }
85
+ end
86
+
87
+ # Shows an "Auto-repairing..." animation while a repair block runs.
88
+ def with_auto_repair(label = "Auto-repairing")
89
+ with_activity(
90
+ label,
91
+ emoji: "🛠️",
92
+ color: :warning,
93
+ interval: 0.08,
94
+ frames: %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏],
95
+ min_duration: 1.0
96
+ ) { yield }
97
+ end
98
+
99
+ private
100
+
101
+ def with_activity(label, emoji:, color:, interval:, frames:, min_duration: 0.0)
102
+ return yield unless block_given?
103
+
104
+ unless @io.respond_to?(:tty?) && @io.tty?
105
+ @io.puts paint("#{emoji} #{label}...", color)
106
+ return yield
107
+ end
108
+
109
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
+ stop = false
111
+ tick = 0
112
+ last_line = ""
113
+ thread =
114
+ Thread.new do
115
+ Thread.current.abort_on_exception = false
116
+ while !stop
117
+ frame = frames[tick % frames.length]
118
+ line = "#{frame} #{emoji} #{label} "
119
+ @io.print "\r#{paint(line, color)}"
120
+ @io.flush if @io.respond_to?(:flush)
121
+ last_line = line
122
+ tick += 1
123
+ sleep interval
124
+ end
125
+ end
126
+
127
+ yield
128
+ ensure
129
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at if defined?(started_at)
130
+ remaining = min_duration.to_f - elapsed.to_f
131
+ sleep remaining if remaining.positive?
132
+ stop = true if defined?(stop)
133
+ thread&.join(0.5)
134
+ if @io.respond_to?(:tty?) && @io.tty?
135
+ @io.print "\r#{' ' * last_line.length}\r"
136
+ @io.puts
137
+ @io.flush if @io.respond_to?(:flush)
138
+ end
139
+ end
140
+
141
+ def paint(text, color_key)
142
+ return text unless color_enabled?
143
+
144
+ color = COLORS[color_key] || ""
145
+ "#{color}#{text}#{RESET}"
146
+ end
147
+
148
+ def color_enabled?
149
+ return false if ENV["NO_COLOR"]
150
+ @io.respond_to?(:tty?) && @io.tty?
151
+ end
152
+
153
+ def render_table(rows)
154
+ headers = ["📦 Model", "🗃️ Table", "🔢 Count"]
155
+ data = rows.map { |r| [r.fetch(:model), r.fetch(:table), r.fetch(:count).to_s] }
156
+
157
+ widths = headers.map(&:length)
158
+ data.each do |row|
159
+ row.each_with_index { |cell, i| widths[i] = [widths[i], cell.to_s.length].max }
160
+ end
161
+
162
+ line = "+-#{widths.map { |w| "-" * w }.join("-+-")}-+"
163
+ out = []
164
+ out << line
165
+ out << "| #{headers.each_with_index.map { |h, i| h.ljust(widths[i]) }.join(" | ")} |"
166
+ out << line
167
+ data.each do |row|
168
+ out << "| #{row.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(" | ")} |"
169
+ end
170
+ out << line
171
+ out.join("\n")
172
+ end
173
+ end
174
+ end
175
+
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AIFaker
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: AIFaker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vaibhav Jain
@@ -37,6 +37,14 @@ files:
37
37
  - README.md
38
38
  - Rakefile
39
39
  - lib/AIFaker.rb
40
+ - lib/AIFaker/client.rb
41
+ - lib/AIFaker/errors.rb
42
+ - lib/AIFaker/llm_adapters/ruby_llm.rb
43
+ - lib/AIFaker/model_introspector.rb
44
+ - lib/AIFaker/schema_introspector.rb
45
+ - lib/AIFaker/seed_planner.rb
46
+ - lib/AIFaker/seed_runner.rb
47
+ - lib/AIFaker/ui.rb
40
48
  - lib/AIFaker/version.rb
41
49
  - sig/AIFaker.rbs
42
50
  homepage: https://github.com/VaibhavDJain/AIFaker
@@ -45,7 +53,8 @@ licenses:
45
53
  metadata:
46
54
  homepage_uri: https://github.com/VaibhavDJain/AIFaker
47
55
  source_code_uri: https://github.com/VaibhavDJain/AIFaker
48
- changelog_uri: https://github.com/VaibhavDJain/AIFaker
56
+ changelog_uri: https://github.com/VaibhavDJain/AIFaker/releases
57
+ documentation_uri: https://www.rubydoc.info/gems/AIFaker
49
58
  post_install_message:
50
59
  rdoc_options: []
51
60
  require_paths: