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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +80 -0
- data/LICENSE.txt +21 -0
- data/README.md +414 -0
- data/exe/rails-agent +7 -0
- data/lib/generators/rails_agent_console/install_generator.rb +17 -0
- data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
- data/lib/rails_agent_console/agent.rb +277 -0
- data/lib/rails_agent_console/cli.rb +114 -0
- data/lib/rails_agent_console/configuration.rb +170 -0
- data/lib/rails_agent_console/console_installer.rb +49 -0
- data/lib/rails_agent_console/console_methods.rb +54 -0
- data/lib/rails_agent_console/console_session.rb +41 -0
- data/lib/rails_agent_console/credentials.rb +66 -0
- data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
- data/lib/rails_agent_console/diagnosis.rb +149 -0
- data/lib/rails_agent_console/errors.rb +20 -0
- data/lib/rails_agent_console/executor.rb +190 -0
- data/lib/rails_agent_console/history.rb +65 -0
- data/lib/rails_agent_console/model_picker.rb +166 -0
- data/lib/rails_agent_console/prompt/dates.rb +34 -0
- data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
- data/lib/rails_agent_console/prompt.rb +122 -0
- data/lib/rails_agent_console/proposal.rb +208 -0
- data/lib/rails_agent_console/providers/anthropic.rb +48 -0
- data/lib/rails_agent_console/providers/base.rb +114 -0
- data/lib/rails_agent_console/providers/callable.rb +28 -0
- data/lib/rails_agent_console/providers/gemini.rb +45 -0
- data/lib/rails_agent_console/providers/ollama.rb +84 -0
- data/lib/rails_agent_console/providers/openai.rb +51 -0
- data/lib/rails_agent_console/providers.rb +28 -0
- data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
- data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
- data/lib/rails_agent_console/query_validator/result.rb +55 -0
- data/lib/rails_agent_console/query_validator/rules.rb +108 -0
- data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
- data/lib/rails_agent_console/query_validator.rb +305 -0
- data/lib/rails_agent_console/railtie.rb +26 -0
- data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
- data/lib/rails_agent_console/rewriter/chain.rb +126 -0
- data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
- data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
- data/lib/rails_agent_console/rewriter/joined.rb +42 -0
- data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
- data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
- data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
- data/lib/rails_agent_console/rewriter.rb +183 -0
- data/lib/rails_agent_console/schema.rb +275 -0
- data/lib/rails_agent_console/setup.rb +40 -0
- data/lib/rails_agent_console/ui.rb +125 -0
- data/lib/rails_agent_console/value_check.rb +85 -0
- data/lib/rails_agent_console/version.rb +5 -0
- data/lib/rails_agent_console/wizard.rb +235 -0
- data/lib/rails_agent_console.rb +58 -0
- metadata +145 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RailsAgentConsole
|
|
7
|
+
# ~/.rails_agent_console/config (0600, inside a 0700 directory):
|
|
8
|
+
#
|
|
9
|
+
# default_provider: openai
|
|
10
|
+
# openai:
|
|
11
|
+
# api_key: sk-...
|
|
12
|
+
# model: gpt-4o-mini
|
|
13
|
+
module Credentials
|
|
14
|
+
DIRECTORY = File.join(Dir.home, ".rails_agent_console")
|
|
15
|
+
PATH = File.join(DIRECTORY, "config")
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def path
|
|
19
|
+
ENV.fetch("RAILS_AGENT_CONSOLE_CONFIG", PATH)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exist?
|
|
23
|
+
File.exist?(path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def read
|
|
27
|
+
return {} unless exist?
|
|
28
|
+
|
|
29
|
+
data = YAML.safe_load_file(path) || {}
|
|
30
|
+
data.is_a?(Hash) ? data : {}
|
|
31
|
+
rescue StandardError
|
|
32
|
+
{}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def store(provider:, api_key: nil, model: nil, base_url: :keep)
|
|
36
|
+
data = read
|
|
37
|
+
data["default_provider"] = provider.to_s
|
|
38
|
+
entry = data[provider.to_s].is_a?(Hash) ? data[provider.to_s] : {}
|
|
39
|
+
entry["api_key"] = api_key if api_key
|
|
40
|
+
entry["model"] = model if model
|
|
41
|
+
store_base_url(entry, base_url)
|
|
42
|
+
data[provider.to_s] = entry
|
|
43
|
+
write(data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def store_base_url(entry, base_url)
|
|
47
|
+
return if base_url == :keep
|
|
48
|
+
|
|
49
|
+
base_url.to_s.strip.empty? ? entry.delete("base_url") : entry["base_url"] = base_url
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def write(data)
|
|
53
|
+
directory = File.dirname(path)
|
|
54
|
+
FileUtils.mkdir_p(directory)
|
|
55
|
+
File.chmod(0o700, directory) if File.owned?(directory) && directory == DIRECTORY
|
|
56
|
+
File.write(path, YAML.dump(data))
|
|
57
|
+
File.chmod(0o600, path)
|
|
58
|
+
path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delete!
|
|
62
|
+
FileUtils.rm_f(path)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Diagnosis
|
|
5
|
+
module Extra
|
|
6
|
+
QUALIFIED_MISSING = /column (\w+)\.(\w+) does not exist/
|
|
7
|
+
BARE_MISSING = /column "(\w+)" does not exist/
|
|
8
|
+
FIND_BY_NAME = /Couldn't find (\w+) with '(\w+)'=([a-z_]\w*)\b/
|
|
9
|
+
WINDOW_IN_AGGREGATE = /aggregate function calls cannot contain window function calls/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def hint(code, message)
|
|
14
|
+
missing_column_hint(code, message) || find_hint(message) || window_hint(message)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def missing_column_hint(code, message)
|
|
18
|
+
table, column = message.match(QUALIFIED_MISSING)&.captures
|
|
19
|
+
column ||= message[BARE_MISSING, 1]
|
|
20
|
+
model = table ? Schema.models.find { |candidate| candidate.table == table } : receiver(code)
|
|
21
|
+
return unless column && model
|
|
22
|
+
|
|
23
|
+
"#{model.table} has no #{column} column. Its columns are " \
|
|
24
|
+
"#{model.column_names.first(30).join(", ")}; use one of them or leave the code empty."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def shared_column(owner, name)
|
|
28
|
+
source = Schema.find(owner)
|
|
29
|
+
target = Schema.models.find { |model| [name, name.pluralize].include?(model.table) }
|
|
30
|
+
return unless source && target
|
|
31
|
+
return if source.inbound.any? { |reference| reference[:table] == target.table }
|
|
32
|
+
|
|
33
|
+
pair = matching_columns(source, target)
|
|
34
|
+
return unless pair
|
|
35
|
+
|
|
36
|
+
"#{source.name} and #{target.name} are not associated, but both hold #{pair.last}: " \
|
|
37
|
+
"#{source.name}.where(#{pair.first}: #{target.name}.select(:#{pair.last}))."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def matching_columns(source, target)
|
|
41
|
+
ignored = %w[id created_at updated_at]
|
|
42
|
+
source.column_names.product(target.column_names).find do |left, right|
|
|
43
|
+
next false if ignored.include?(left) || ignored.include?(right)
|
|
44
|
+
|
|
45
|
+
left == right || left.end_with?("_#{right}") || right.end_with?("_#{left}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def receiver(code)
|
|
50
|
+
Schema.find(code.to_s[/\A\s*([A-Z]\w*(?:::[A-Z]\w*)*)/, 1].to_s)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def find_hint(message)
|
|
54
|
+
model, key, value = message.match(FIND_BY_NAME)&.captures
|
|
55
|
+
return unless model
|
|
56
|
+
|
|
57
|
+
"find(:#{value}) looks up the literal value #{value.inspect} in #{model}.#{key}. Comparing " \
|
|
58
|
+
"with a column of another table needs a join and SQL, such as " \
|
|
59
|
+
"joins(:association).where(\"table_a.column <> table_b.column\")."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def window_hint(message)
|
|
63
|
+
return unless WINDOW_IN_AGGREGATE.match?(message)
|
|
64
|
+
|
|
65
|
+
"A window function (LAG, ROW_NUMBER) cannot sit inside an aggregate. Compute it in a " \
|
|
66
|
+
"subquery and aggregate the outer query: " \
|
|
67
|
+
"Model.from(Model.select(\"model_table.*, LAG(created_at) OVER (PARTITION BY customer_id " \
|
|
68
|
+
"ORDER BY created_at) AS previous_at\"), :model_table).average(...)."
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Diagnosis
|
|
5
|
+
MISSING_COLUMN = /column "([^"]+)" does not exist/
|
|
6
|
+
NESTED_AGGREGATE = /aggregate function calls cannot be nested/
|
|
7
|
+
MISSING_GROUP = /column "([^"]+)" must appear in the GROUP BY clause/
|
|
8
|
+
MISSING_ASSOCIATION = /Can't join '([^']+)' to association named '([^']+)'/
|
|
9
|
+
MISSING_TABLE = /missing FROM-clause entry for table "([^"]+)"/
|
|
10
|
+
ALIAS_IN_CLAUSE = /syntax error at or near "AS"/
|
|
11
|
+
HASH_CHAIN = /undefined method [`']([^`']+)['`] for .*Hash/m
|
|
12
|
+
NOT_A_TIME = /invalid input syntax for type (timestamp|date)[^:]*: "([^"]*)"/
|
|
13
|
+
TYPE_MISMATCH = /operator does not exist: (\w[\w ]*) = (\w[\w ]*)/
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def hint(code, message)
|
|
17
|
+
text = message.to_s
|
|
18
|
+
alias_hint(code, text) || nested_aggregate_hint(text) || group_by_hint(text) ||
|
|
19
|
+
association_hint(text) || missing_table_hint(text) || type_mismatch_hint(text) ||
|
|
20
|
+
alias_in_clause_hint(text) || hash_chain_hint(text) || not_a_time_hint(text) ||
|
|
21
|
+
Extra.hint(code, text)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def alias_hint(code, message)
|
|
27
|
+
name = message[MISSING_COLUMN, 1]
|
|
28
|
+
return unless name && aliased?(code, name)
|
|
29
|
+
|
|
30
|
+
"`#{name}` is an alias from select(...). SQL cannot see it anywhere else in the " \
|
|
31
|
+
"same query, so repeat the expression instead of naming the alias."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def aliased?(code, name)
|
|
35
|
+
code.to_s.match?(/\bAS\s+"?#{Regexp.escape(name)}"?\b/i)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def nested_aggregate_hint(message)
|
|
39
|
+
return unless NESTED_AGGREGATE.match?(message)
|
|
40
|
+
|
|
41
|
+
"One aggregate cannot wrap another. Divide two separate aggregates instead, " \
|
|
42
|
+
"such as Model.count.to_f / Model.distinct.count(:customer_id)."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def group_by_hint(message)
|
|
46
|
+
name = message[MISSING_GROUP, 1]
|
|
47
|
+
return unless name
|
|
48
|
+
|
|
49
|
+
"Every column that is selected has to be either grouped by or wrapped in an " \
|
|
50
|
+
"aggregate. Group by `#{name}` as well, or leave it out of the select."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def association_hint(message)
|
|
54
|
+
match = MISSING_ASSOCIATION.match(message)
|
|
55
|
+
return unless match
|
|
56
|
+
|
|
57
|
+
owner, name = match.captures
|
|
58
|
+
return column_hint(owner, name) if column?(owner, name)
|
|
59
|
+
|
|
60
|
+
["There is no `#{name}` association on #{owner}, and there never will be: nothing",
|
|
61
|
+
"declares it. #{ways_around(owner, name)}"].join(" ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def column_hint(owner, name)
|
|
65
|
+
"`#{name}` is a column on #{owner}, not an association. Read it or filter on it " \
|
|
66
|
+
"directly -- #{owner}.where(#{name}: nil) -- with no join at all."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def column?(owner, name)
|
|
70
|
+
Schema.find(owner)&.column_names&.include?(name.to_s) || false
|
|
71
|
+
rescue StandardError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def not_a_time_hint(message)
|
|
76
|
+
type, value = NOT_A_TIME.match(message)&.captures
|
|
77
|
+
return unless type
|
|
78
|
+
|
|
79
|
+
"A #{type} column was compared with #{value.inspect}, which is not a #{type}. A condition " \
|
|
80
|
+
"on part of a date is evaluated per row in SQL: " \
|
|
81
|
+
"where(\"EXTRACT(DOW FROM created_at) IN (0, 6)\") for weekends, EXTRACT(HOUR FROM ...) for hours."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def hash_chain_hint(message)
|
|
85
|
+
name = message[HASH_CHAIN, 1]
|
|
86
|
+
return unless name
|
|
87
|
+
|
|
88
|
+
"count, sum and average on a grouped relation return a Hash of results, so `#{name}` " \
|
|
89
|
+
"has nothing to act on. Chain everything onto the relation first and aggregate last: " \
|
|
90
|
+
"group(:source).order(Arel.sql(\"AVG(LENGTH(query)) DESC\")).average(Arel.sql(\"LENGTH(query)\"))."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ways_around(owner, name)
|
|
94
|
+
shared = Extra.shared_column(owner, name)
|
|
95
|
+
return shared if shared
|
|
96
|
+
|
|
97
|
+
other = referring_model(owner, name)
|
|
98
|
+
return "Answer it from the model that declares the belongs_to instead." unless other
|
|
99
|
+
|
|
100
|
+
"Either start from #{other[:model]} and join back -- " \
|
|
101
|
+
"#{other[:model]}.joins(:#{other[:association]}) -- or keep #{owner} as the subject and " \
|
|
102
|
+
"filter with a subquery: #{owner}.where(id: #{other[:model]}.select(:#{other[:foreign_key]})), " \
|
|
103
|
+
"or where.not for the ones without."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def referring_model(owner, name)
|
|
107
|
+
inbound = Schema.find(owner)&.inbound
|
|
108
|
+
return if inbound.nil? || inbound.empty?
|
|
109
|
+
|
|
110
|
+
inbound.find { |reference| same_model?(reference[:model], name) } || inbound.first
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def missing_table_hint(message)
|
|
116
|
+
table = message[MISSING_TABLE, 1]
|
|
117
|
+
return unless table
|
|
118
|
+
|
|
119
|
+
"The query reads columns from `#{table}` without that table being in the query. " \
|
|
120
|
+
"Move the condition into a subquery on #{table} -- counting rows per owner is " \
|
|
121
|
+
"where(id: Other.group(:owner_id).having(Arel.sql(\"COUNT(*) > 10\")).select(:owner_id)) -- " \
|
|
122
|
+
"or drop it: the column you want may well be on the table you already select from."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def alias_in_clause_hint(message)
|
|
126
|
+
return unless ALIAS_IN_CLAUSE.match?(message)
|
|
127
|
+
|
|
128
|
+
"Only a select list may name an alias with AS. Pass group(...) and order(...) the " \
|
|
129
|
+
"expression itself, as a separate argument per column: " \
|
|
130
|
+
"group(Arel.sql(\"EXTRACT(YEAR FROM created_at)\"), :source).count."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def type_mismatch_hint(message)
|
|
134
|
+
left, right = TYPE_MISMATCH.match(message)&.captures
|
|
135
|
+
return unless left
|
|
136
|
+
|
|
137
|
+
"A #{left.strip} column is being compared with a #{right.strip} one, which is what " \
|
|
138
|
+
"where(id: Model.select(:some_string)) does. Compare columns that hold the same kind " \
|
|
139
|
+
"of value: if one table refers to another by email rather than by id, join the emails."
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def same_model?(model, name)
|
|
143
|
+
model.to_s.split("::").last.downcase == name.to_s.delete("_").sub(/s\z/, "")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
require_relative "diagnosis/extra"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
|
|
8
|
+
class InvalidResponseError < Error; end
|
|
9
|
+
|
|
10
|
+
class ProviderError < Error; end
|
|
11
|
+
|
|
12
|
+
class UnsafeCodeError < Error
|
|
13
|
+
attr_reader :violations
|
|
14
|
+
|
|
15
|
+
def initialize(message, violations: [])
|
|
16
|
+
super(message)
|
|
17
|
+
@violations = violations
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module RailsAgentConsole
|
|
6
|
+
module Executor
|
|
7
|
+
AGGREGATES = %w[count sum average minimum maximum size length pluck ids exists? any? empty? tally].freeze
|
|
8
|
+
DESTRUCTIVE_TAIL = /\.\s*(delete_all|destroy_all|update_all|delete|destroy)\s*(\([^()]*\))?\s*\z/m
|
|
9
|
+
ASSIGNMENT = /(?<![=!<>])=(?![=~>])/
|
|
10
|
+
NO_AGGREGATE_NOTE = "The query had no aggregate after `group`, so the groups were counted."
|
|
11
|
+
GROUPED_ROWS_NOTE = "Grouped rows have no id, so each one is shown as its selected values."
|
|
12
|
+
COMPUTED_NOTE = "The rows carry computed columns a record would hide, so each one is shown as its values."
|
|
13
|
+
|
|
14
|
+
Outcome = Struct.new(:value, :error, :duration, :note, keyword_init: true) do
|
|
15
|
+
def ok?
|
|
16
|
+
error.nil?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def run(code)
|
|
22
|
+
started = now
|
|
23
|
+
raw = with_timeout { evaluate(code) }
|
|
24
|
+
value, note = resolve(raw)
|
|
25
|
+
duration = now - started
|
|
26
|
+
notes = [note, *value_notes(code, raw)].compact
|
|
27
|
+
Outcome.new(value: value, error: nil, duration: duration, note: notes.empty? ? nil : notes.join("\n"))
|
|
28
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
29
|
+
Outcome.new(value: nil, error: e, duration: now - started)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def preflight_count(code)
|
|
33
|
+
return unless RailsAgentConsole.configuration.preflight_count
|
|
34
|
+
return if aggregate?(code)
|
|
35
|
+
|
|
36
|
+
count_rows(code)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def impact_estimate(code)
|
|
40
|
+
stripped = code.sub(DESTRUCTIVE_TAIL, "")
|
|
41
|
+
return if stripped == code || stripped.strip.empty?
|
|
42
|
+
|
|
43
|
+
count_rows(stripped)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def describe(value)
|
|
47
|
+
limit = RailsAgentConsole.configuration.inspect_limit
|
|
48
|
+
|
|
49
|
+
case value
|
|
50
|
+
when ->(v) { relation?(v) } then describe_relation(value)
|
|
51
|
+
when Array then pluralize(value.size, "item")
|
|
52
|
+
when Hash then pluralize(value.size, "group")
|
|
53
|
+
when nil then "nil"
|
|
54
|
+
else truncate(value.inspect, limit * 20)
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError
|
|
57
|
+
value.class.name.to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def relation?(value)
|
|
61
|
+
defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sandbox_binding
|
|
65
|
+
@sandbox_binding ||= Object.new.instance_eval { binding }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset!
|
|
69
|
+
@sandbox_binding = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def count_rows(code)
|
|
75
|
+
counting = "(#{code}).count(:all)"
|
|
76
|
+
return unless QueryValidator.validate(counting, write_mode: false).valid?
|
|
77
|
+
|
|
78
|
+
value = with_timeout { evaluate(counting) }
|
|
79
|
+
value if value.is_a?(Integer)
|
|
80
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve(value)
|
|
85
|
+
return [readable_keys(value), nil] if value.is_a?(Hash)
|
|
86
|
+
return [value, nil] unless relation?(value) && !value.loaded?
|
|
87
|
+
return [readable_keys(value.count), NO_AGGREGATE_NOTE] if bare_group?(value)
|
|
88
|
+
return [value.load, nil] unless grouped_select?(value)
|
|
89
|
+
|
|
90
|
+
rows = value.to_a
|
|
91
|
+
return [value, nil] if rows.empty?
|
|
92
|
+
return [rows.map { |row| grouped_row(row) }, GROUPED_ROWS_NOTE] if rows.all? { |row| row.id.nil? }
|
|
93
|
+
return [rows.map { |row| grouped_row(row) }, COMPUTED_NOTE] if computed_columns?(value, rows)
|
|
94
|
+
|
|
95
|
+
[value, nil]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def computed_columns?(relation, rows)
|
|
99
|
+
(rows.first.attributes.keys - relation.klass.column_names).any?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def value_notes(code, raw)
|
|
103
|
+
relation = raw if relation?(raw)
|
|
104
|
+
base = Prompt::FollowUp.base_of(code)
|
|
105
|
+
relation ||= evaluate(base) if base && base != code.strip && !assigns?(code)
|
|
106
|
+
relation?(relation) ? ValueCheck.notes(relation) : []
|
|
107
|
+
rescue StandardError
|
|
108
|
+
[]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def assigns?(code)
|
|
112
|
+
code.gsub(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "''").match?(ASSIGNMENT)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def grouped_row(row)
|
|
116
|
+
row.attributes.compact.transform_values do |value|
|
|
117
|
+
value.is_a?(BigDecimal) && !value.frac.zero? ? value.to_f : whole(value)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def readable_keys(hash)
|
|
122
|
+
return hash unless hash.keys.flatten.any?(BigDecimal)
|
|
123
|
+
|
|
124
|
+
hash.transform_keys { |key| key.is_a?(Array) ? key.map { |part| whole(part) } : whole(key) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def whole(value)
|
|
128
|
+
value.is_a?(BigDecimal) && value.frac.zero? ? value.to_i : value
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def bare_group?(value)
|
|
132
|
+
value.group_values.any? && value.select_values.empty?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def grouped_select?(value)
|
|
136
|
+
value.group_values.any? && value.select_values.any?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def describe_relation(relation)
|
|
140
|
+
size = relation.size
|
|
141
|
+
size.is_a?(Hash) ? pluralize(size.size, "group") : pluralize(size, "record")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def evaluate(code)
|
|
145
|
+
eval(code, sandbox_binding, "(rails-agent)", 1) # rubocop:disable Security/Eval
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def aggregate?(code)
|
|
149
|
+
AGGREGATES.include?(trailing_method(code).to_s)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def trailing_method(code)
|
|
153
|
+
source = code.strip
|
|
154
|
+
source = strip_trailing_arguments(source) if source.end_with?(")")
|
|
155
|
+
source[/([a-z_]\w*[?!]?)\s*\z/, 1]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def strip_trailing_arguments(source)
|
|
159
|
+
depth = 0
|
|
160
|
+
source.length.downto(1) do |index|
|
|
161
|
+
case source[index - 1]
|
|
162
|
+
when ")" then depth += 1
|
|
163
|
+
when "(" then depth -= 1
|
|
164
|
+
end
|
|
165
|
+
return source[0, index - 1] if depth.zero?
|
|
166
|
+
end
|
|
167
|
+
source
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def with_timeout(&block)
|
|
171
|
+
seconds = RailsAgentConsole.configuration.execution_timeout
|
|
172
|
+
return block.call if seconds.nil? || seconds.to_i <= 0
|
|
173
|
+
|
|
174
|
+
Timeout.timeout(seconds.to_i, &block)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def pluralize(count, noun)
|
|
178
|
+
"#{count} #{count == 1 ? noun : "#{noun}s"}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def truncate(string, length)
|
|
182
|
+
string.length > length ? "#{string[0, length]}..." : string
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def now
|
|
186
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsAgentConsole
|
|
6
|
+
class History
|
|
7
|
+
Turn = Struct.new(:prompt, :code, :explanation, :outcome, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize(limit: nil)
|
|
10
|
+
@limit = limit
|
|
11
|
+
@turns = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def limit
|
|
15
|
+
@limit || RailsAgentConsole.configuration.max_history
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(prompt:, code:, explanation: nil, outcome: nil)
|
|
19
|
+
@turns << Turn.new(prompt: prompt, code: code, explanation: explanation, outcome: outcome)
|
|
20
|
+
@turns.shift while @turns.size > limit
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def turns
|
|
25
|
+
@turns.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def last
|
|
29
|
+
@turns.last
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def empty?
|
|
33
|
+
@turns.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear
|
|
37
|
+
@turns.clear
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def messages
|
|
42
|
+
@turns.flat_map do |turn|
|
|
43
|
+
[
|
|
44
|
+
{ role: "user", content: turn.prompt },
|
|
45
|
+
{ role: "assistant", content: assistant_content(turn) }
|
|
46
|
+
]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_s
|
|
51
|
+
return "(no queries yet)" if empty?
|
|
52
|
+
|
|
53
|
+
@turns.map.with_index(1) { |turn, i| "#{i}. #{turn.prompt}\n #{turn.code}" }.join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def assistant_content(turn)
|
|
59
|
+
payload = { "code" => turn.code }
|
|
60
|
+
payload["explanation"] = turn.explanation if turn.explanation
|
|
61
|
+
payload["result"] = turn.outcome if turn.outcome
|
|
62
|
+
JSON.generate(payload)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|