rails_agent_console 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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +414 -0
  5. data/exe/rails-agent +7 -0
  6. data/lib/generators/rails_agent_console/install_generator.rb +17 -0
  7. data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
  8. data/lib/rails_agent_console/agent.rb +277 -0
  9. data/lib/rails_agent_console/cli.rb +114 -0
  10. data/lib/rails_agent_console/configuration.rb +170 -0
  11. data/lib/rails_agent_console/console_installer.rb +49 -0
  12. data/lib/rails_agent_console/console_methods.rb +54 -0
  13. data/lib/rails_agent_console/console_session.rb +41 -0
  14. data/lib/rails_agent_console/credentials.rb +66 -0
  15. data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
  16. data/lib/rails_agent_console/diagnosis.rb +149 -0
  17. data/lib/rails_agent_console/errors.rb +20 -0
  18. data/lib/rails_agent_console/executor.rb +190 -0
  19. data/lib/rails_agent_console/history.rb +65 -0
  20. data/lib/rails_agent_console/model_picker.rb +166 -0
  21. data/lib/rails_agent_console/prompt/dates.rb +34 -0
  22. data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
  23. data/lib/rails_agent_console/prompt.rb +122 -0
  24. data/lib/rails_agent_console/proposal.rb +208 -0
  25. data/lib/rails_agent_console/providers/anthropic.rb +48 -0
  26. data/lib/rails_agent_console/providers/base.rb +114 -0
  27. data/lib/rails_agent_console/providers/callable.rb +28 -0
  28. data/lib/rails_agent_console/providers/gemini.rb +45 -0
  29. data/lib/rails_agent_console/providers/ollama.rb +84 -0
  30. data/lib/rails_agent_console/providers/openai.rb +51 -0
  31. data/lib/rails_agent_console/providers.rb +28 -0
  32. data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
  33. data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
  34. data/lib/rails_agent_console/query_validator/result.rb +55 -0
  35. data/lib/rails_agent_console/query_validator/rules.rb +108 -0
  36. data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
  37. data/lib/rails_agent_console/query_validator.rb +305 -0
  38. data/lib/rails_agent_console/railtie.rb +26 -0
  39. data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
  40. data/lib/rails_agent_console/rewriter/chain.rb +126 -0
  41. data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
  42. data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
  43. data/lib/rails_agent_console/rewriter/joined.rb +42 -0
  44. data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
  45. data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
  46. data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
  47. data/lib/rails_agent_console/rewriter.rb +183 -0
  48. data/lib/rails_agent_console/schema.rb +275 -0
  49. data/lib/rails_agent_console/setup.rb +40 -0
  50. data/lib/rails_agent_console/ui.rb +125 -0
  51. data/lib/rails_agent_console/value_check.rb +85 -0
  52. data/lib/rails_agent_console/version.rb +5 -0
  53. data/lib/rails_agent_console/wizard.rb +235 -0
  54. data/lib/rails_agent_console.rb +58 -0
  55. metadata +145 -0
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Schema
5
+ INTERNAL_PREFIXES = %w[
6
+ ActiveRecord:: ActiveStorage:: ActionText:: ActionMailbox::
7
+ SolidQueue:: SolidCache:: SolidCable:: Delayed:: PgSearch::
8
+ ].freeze
9
+
10
+ Model = Struct.new(:name, :table, :columns, :associations, :referenced_by, :abstract,
11
+ keyword_init: true) do
12
+ def column_names
13
+ columns.map(&:first)
14
+ end
15
+
16
+ def association_names
17
+ associations.map { |association| association[:name].to_s }
18
+ end
19
+
20
+ def keywords
21
+ [name.downcase, *name.gsub(/([a-z])([A-Z])/, '\1 \2').downcase.split, table.to_s,
22
+ *column_names, *association_names].uniq
23
+ end
24
+
25
+ def to_context(columns: true)
26
+ lines = ["#{name} (#{table})"]
27
+ lines << " columns: #{column_list}" if columns
28
+ associations.each { |association| lines << " #{association_line(association)}" }
29
+ lines += inbound_lines.map { |line| " #{line}" } if inbound.any?
30
+ lines.join("\n")
31
+ end
32
+
33
+ def inbound
34
+ Array(referenced_by)
35
+ end
36
+
37
+ def inbound_lines
38
+ ["#{missing_joins} all raise: no such association exists here.",
39
+ "query from the other side instead -- #{inbound_list} -- " \
40
+ "or filter with a subquery: #{subquery_example}"]
41
+ end
42
+
43
+ def missing_joins
44
+ inbound.map { |reference| "#{name}.joins(:#{reference[:table]})" }.join(" and ")
45
+ end
46
+
47
+ def inbound_list
48
+ inbound.map do |reference|
49
+ "#{reference[:model]}.joins(:#{reference[:association]}) (#{reference[:foreign_key]})"
50
+ end.join(", ")
51
+ end
52
+
53
+ def subquery_example
54
+ reference = inbound.first
55
+ "#{name}.where(id: #{reference[:model]}.select(:#{reference[:foreign_key]}))"
56
+ end
57
+
58
+ def column_list
59
+ columns.map { |name, type, extra| extra ? "#{name}:#{type} #{extra}" : "#{name}:#{type}" }.join(", ")
60
+ end
61
+
62
+ def association_line(association)
63
+ line = "#{association[:macro]} :#{association[:name]}"
64
+ line += " (#{association[:class_name]})" if association[:class_name] != default_class_name(association)
65
+ line += " through: :#{association[:through]}" if association[:through]
66
+ line
67
+ end
68
+
69
+ private
70
+
71
+ def default_class_name(association)
72
+ base = association[:name].to_s
73
+ base = base.sub(/s\z/, "") if %i[has_many has_and_belongs_to_many].include?(association[:macro])
74
+ base.split("_").map(&:capitalize).join
75
+ end
76
+ end
77
+
78
+ class << self
79
+ def models
80
+ @models ||= load_models
81
+ end
82
+
83
+ def model_names
84
+ models.map(&:name)
85
+ end
86
+
87
+ def find(name)
88
+ models.find { |model| model.name == name.to_s }
89
+ end
90
+
91
+ def reset!
92
+ @models = nil
93
+ self
94
+ end
95
+
96
+ def context(prompt = nil)
97
+ return "No ActiveRecord models found." if models.empty?
98
+
99
+ focused, rest = split_by_relevance(prompt)
100
+ sections = [header]
101
+ sections << focused.map(&:to_context).join("\n\n")
102
+ sections << "Other models: #{rest.map(&:name).sort.join(", ")}" if rest.any?
103
+ sections << RailsAgentConsole.configuration.extra_context if RailsAgentConsole.configuration.extra_context
104
+ sections.compact.join("\n\n")
105
+ end
106
+
107
+ def tree(names)
108
+ Array(names).flat_map do |name|
109
+ model = find(name)
110
+ next [] unless model
111
+
112
+ lines = [model.name]
113
+ model.associations.each_with_index do |association, index|
114
+ last = index == model.associations.size - 1
115
+ lines << " #{last ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"} #{model.association_line(association)}"
116
+ end
117
+ lines
118
+ end
119
+ end
120
+
121
+ def inspect
122
+ context
123
+ end
124
+
125
+ def print(prompt = nil)
126
+ RailsAgentConsole.configuration.out.puts(context(prompt))
127
+ nil
128
+ end
129
+
130
+ private
131
+
132
+ def header
133
+ parts = []
134
+ parts << "Rails #{Rails.version}" if defined?(Rails) && Rails.respond_to?(:version)
135
+ parts << "adapter: #{adapter_name}" if adapter_name
136
+ parts.empty? ? "Application schema:" : parts.join(", ").to_s
137
+ end
138
+
139
+ def adapter_name
140
+ return unless active_record?
141
+
142
+ ActiveRecord::Base.connection_db_config.adapter
143
+ rescue StandardError
144
+ nil
145
+ end
146
+
147
+ def split_by_relevance(prompt)
148
+ config = RailsAgentConsole.configuration
149
+ all = models.first(config.max_models)
150
+ return [all, models.drop(config.max_models)] if prompt.nil? || all.size <= config.max_focused_models
151
+
152
+ scored = all.sort_by { |model| [-relevance(model, prompt), model.name] }
153
+ focused = scored.first(config.max_focused_models)
154
+ focused = focused.sort_by(&:name)
155
+ [focused, all - focused]
156
+ end
157
+
158
+ def relevance(model, prompt)
159
+ tokens = prompt.to_s.downcase.scan(/[a-z_]{3,}/).map { |token| token.sub(/(ies|es|s)\z/, "") }
160
+ return 0 if tokens.empty?
161
+
162
+ model.keywords.sum do |keyword|
163
+ stem = keyword.sub(/(ies|es|s)\z/, "")
164
+ tokens.count { |token| stem.include?(token) || token.include?(stem) }
165
+ end
166
+ end
167
+
168
+ def load_models
169
+ return [] unless active_record?
170
+
171
+ eager_load!
172
+ candidates = ActiveRecord::Base.descendants.reject do |klass|
173
+ klass.name.nil? || klass.abstract_class? || internal?(klass.name) || excluded?(klass.name)
174
+ end
175
+
176
+ described = candidates.uniq(&:name).sort_by(&:name).filter_map { |klass| describe(klass) }
177
+ link_inbound(described)
178
+ end
179
+
180
+ def link_inbound(described)
181
+ by_name = described.to_h { |model| [model.name, model] }
182
+
183
+ described.each do |model|
184
+ model.associations.select { |association| association[:macro] == :belongs_to }.each do |association|
185
+ target = by_name[association[:class_name]]
186
+ next if target.nil? || target.name == model.name
187
+ next if target.associations.any? { |inverse| inverse[:class_name] == model.name }
188
+
189
+ target.referenced_by << inbound_reference(model, association)
190
+ end
191
+ end
192
+
193
+ described
194
+ end
195
+
196
+ def inbound_reference(model, association)
197
+ { model: model.name, association: association[:name],
198
+ foreign_key: association[:foreign_key], table: model.table }
199
+ end
200
+
201
+ def describe(klass)
202
+ return unless klass.table_exists?
203
+
204
+ Model.new(
205
+ name: klass.name,
206
+ table: klass.table_name,
207
+ columns: columns_for(klass),
208
+ associations: associations_for(klass),
209
+ referenced_by: [],
210
+ abstract: false
211
+ )
212
+ rescue StandardError
213
+ nil
214
+ end
215
+
216
+ def columns_for(klass)
217
+ klass.columns.map do |column|
218
+ extra = []
219
+ extra << "pk" if column.name == klass.primary_key
220
+ extra << "null" if column.null && column.name != klass.primary_key
221
+ [column.name, column.type, extra.empty? ? nil : "(#{extra.join(",")})"]
222
+ end
223
+ end
224
+
225
+ def associations_for(klass)
226
+ klass.reflect_on_all_associations.map do |reflection|
227
+ {
228
+ macro: reflection.macro,
229
+ name: reflection.name,
230
+ class_name: safe_class_name(reflection),
231
+ foreign_key: safe_foreign_key(reflection),
232
+ through: reflection.options[:through]
233
+ }
234
+ end
235
+ rescue StandardError
236
+ []
237
+ end
238
+
239
+ def safe_class_name(reflection)
240
+ reflection.options[:class_name]&.to_s || reflection.class_name
241
+ rescue StandardError
242
+ nil
243
+ end
244
+
245
+ def safe_foreign_key(reflection)
246
+ reflection.foreign_key.to_s
247
+ rescue StandardError
248
+ nil
249
+ end
250
+
251
+ def internal?(name)
252
+ return false if RailsAgentConsole.configuration.include_internal_models
253
+
254
+ INTERNAL_PREFIXES.any? { |prefix| name.start_with?(prefix) }
255
+ end
256
+
257
+ def excluded?(name)
258
+ RailsAgentConsole.configuration.excluded_models.map(&:to_s).include?(name)
259
+ end
260
+
261
+ def eager_load!
262
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
263
+ return if Rails.application.config.eager_load
264
+
265
+ Rails.application.eager_load!
266
+ rescue StandardError
267
+ nil
268
+ end
269
+
270
+ def active_record?
271
+ defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:descendants)
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Setup
5
+ PROVIDER_LABELS = {
6
+ openai: "OpenAI",
7
+ anthropic: "Anthropic",
8
+ gemini: "Gemini",
9
+ ollama: "Ollama"
10
+ }.freeze
11
+
12
+ class << self
13
+ def interactive?(config = RailsAgentConsole.configuration)
14
+ input = config.in
15
+ input.respond_to?(:tty?) && input.tty?
16
+ end
17
+
18
+ def needed?(config = RailsAgentConsole.configuration)
19
+ !config.api_key?
20
+ end
21
+
22
+ def run(config: RailsAgentConsole.configuration, ui: UI.new(config))
23
+ ui.blank
24
+ ui.heading "Rails Agent Console"
25
+ ui.say "No AI provider configured yet. A few short questions; Enter takes the suggestion."
26
+ return false unless Wizard.run(config: config, ui: ui)
27
+
28
+ ui.success "Ready. Try: ai \"how many #{example_model.downcase} records do we have?\""
29
+ ui.blank
30
+ true
31
+ end
32
+
33
+ private
34
+
35
+ def example_model
36
+ Schema.model_names.first || "User"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ class UI
5
+ COLORS = {
6
+ black: 30, red: 31, green: 32, yellow: 33,
7
+ blue: 34, magenta: 35, cyan: 36, white: 37, gray: 90
8
+ }.freeze
9
+
10
+ ROBOT = "\u{1F916}"
11
+ WARNING = "\u26A0\uFE0F"
12
+ CHECK = "\u2713"
13
+ CROSS = "\u2717"
14
+
15
+ def initialize(config = RailsAgentConsole.configuration)
16
+ @config = config
17
+ end
18
+
19
+ def say(text = "")
20
+ out.puts(text)
21
+ end
22
+
23
+ def blank
24
+ out.puts
25
+ end
26
+
27
+ def heading(text)
28
+ say "#{ROBOT} #{bold(text)}"
29
+ end
30
+
31
+ def note(text)
32
+ say dim(text)
33
+ end
34
+
35
+ def success(text)
36
+ say "#{colorize(CHECK, :green)} #{text}"
37
+ end
38
+
39
+ def failure(text)
40
+ say "#{colorize(CROSS, :red)} #{text}"
41
+ end
42
+
43
+ def warn(text)
44
+ say "#{WARNING} #{colorize(text, :yellow)}"
45
+ end
46
+
47
+ def code(source)
48
+ source.to_s.each_line do |line|
49
+ say " #{colorize(line.chomp, :cyan)}"
50
+ end
51
+ end
52
+
53
+ def tree(lines)
54
+ lines.each { |line| say " #{dim(line)}" }
55
+ end
56
+
57
+ def ask(question, default: nil)
58
+ out.print("#{question} ")
59
+ out.flush
60
+ answer = read_line
61
+ return default if answer.nil? || answer.strip.empty?
62
+
63
+ answer.strip
64
+ end
65
+
66
+ def yes?(question, default: false)
67
+ suffix = default ? "[Y/n]" : "[y/N]"
68
+ answer = ask("#{question} #{suffix}", default: nil)
69
+ return default if answer.nil?
70
+
71
+ case answer.downcase
72
+ when "y", "yes" then true
73
+ when "n", "no", "" then false
74
+ else default
75
+ end
76
+ end
77
+
78
+ def secret(question)
79
+ out.print("#{question} ")
80
+ out.flush
81
+ value = read_secret
82
+ out.puts
83
+ value.to_s.strip
84
+ end
85
+
86
+ def bold(text)
87
+ @config.color? ? "\e[1m#{text}\e[0m" : text.to_s
88
+ end
89
+
90
+ def dim(text)
91
+ @config.color? ? "\e[2m#{text}\e[0m" : text.to_s
92
+ end
93
+
94
+ def colorize(text, color)
95
+ return text.to_s unless @config.color?
96
+
97
+ "\e[#{COLORS.fetch(color, 37)}m#{text}\e[0m"
98
+ end
99
+
100
+ private
101
+
102
+ def out
103
+ @config.out
104
+ end
105
+
106
+ def read_line
107
+ @config.in.gets
108
+ end
109
+
110
+ def read_secret
111
+ input = @config.in
112
+ require "io/console"
113
+
114
+ return input.gets.to_s.chomp unless echo_controllable?(input)
115
+
116
+ input.noecho { input.gets.to_s.chomp }
117
+ rescue LoadError, Errno::ENOTTY, Errno::ENODEV
118
+ input.gets.to_s.chomp
119
+ end
120
+
121
+ def echo_controllable?(input)
122
+ input.respond_to?(:noecho) && input.respond_to?(:tty?) && input.tty?
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ # Spots a condition that compares a column with a value the column never holds,
5
+ # such as country: "Croatia" where the data says "HR". The values it lists are
6
+ # printed in the console only; nothing here is sent to the model.
7
+ module ValueCheck
8
+ MAX_DISTINCT = 30
9
+ SHOWN = 10
10
+ TEXT_TYPES = %i[string text citext].freeze
11
+
12
+ module_function
13
+
14
+ def notes(relation)
15
+ conditions(relation.where_clause.ast).uniq.filter_map { |table, column, value| note(table, column, value) }
16
+ rescue StandardError
17
+ []
18
+ end
19
+
20
+ def conditions(node)
21
+ case node
22
+ when Arel::Nodes::HomogeneousIn
23
+ pairs(node.attribute, node.values)
24
+ when Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::In, Arel::Nodes::NotIn
25
+ comparison(node)
26
+ when Arel::Nodes::Binary, Arel::Nodes::And, Arel::Nodes::Or, Arel::Nodes::Grouping, Arel::Nodes::Not
27
+ children(node).flat_map { |child| conditions(child) }
28
+ else
29
+ []
30
+ end
31
+ end
32
+
33
+ def children(node)
34
+ return node.children if node.respond_to?(:children)
35
+ return [node.expr] if node.respond_to?(:expr)
36
+
37
+ [node.left, node.right]
38
+ end
39
+
40
+ def comparison(node)
41
+ pairs(node.left, node.right)
42
+ end
43
+
44
+ def pairs(attribute, values)
45
+ return [] unless attribute.is_a?(Arel::Attributes::Attribute)
46
+
47
+ Array(values).filter_map { |value| literal(value) }
48
+ .map { |value| [attribute.relation.name.to_s, attribute.name.to_s, value] }
49
+ end
50
+
51
+ def literal(node)
52
+ value = node.respond_to?(:value) ? node.value : node
53
+ value = value.value_before_type_cast if value.respond_to?(:value_before_type_cast)
54
+ value if value.is_a?(String) && !value.empty?
55
+ end
56
+
57
+ def note(table, column, value)
58
+ klass = model_for(table, column)
59
+ return if klass.nil?
60
+
61
+ counts = klass.unscoped.where.not(column => nil).group(column).order(Arel.sql("COUNT(*) DESC"))
62
+ .limit(MAX_DISTINCT + 1).count
63
+ return if counts.size > MAX_DISTINCT || counts.key?(value)
64
+
65
+ "#{table}.#{column} never holds #{value.inspect}. #{listing(counts.keys, value)}"
66
+ end
67
+
68
+ def listing(values, wanted)
69
+ close = values.find { |candidate| candidate.to_s.casecmp?(wanted) }
70
+ return "It holds #{close.inspect}, with different capitals." if close
71
+
72
+ shown = values.first(SHOWN).join(", ")
73
+ more = values.size > SHOWN ? " (+#{values.size - SHOWN} more)" : ""
74
+ "The values it holds are: #{shown}#{more}."
75
+ end
76
+
77
+ def model_for(table, column)
78
+ model = Schema.models.find { |candidate| candidate.table == table }
79
+ return if model.nil?
80
+
81
+ type = model.columns.find { |name, _| name == column }&.dig(1)
82
+ Object.const_get(model.name) if TEXT_TYPES.include?(type)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ VERSION = "0.1.0"
5
+ end