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,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
# Usage:
|
|
5
|
+
# ai_model # what is in use, then a guided change
|
|
6
|
+
# ai_model "gpt-4o" # the provider follows from the name
|
|
7
|
+
# ai_model "ollama/qwen2.5-coder:7b" # a provider, and its model
|
|
8
|
+
module ModelPicker
|
|
9
|
+
class << self
|
|
10
|
+
def run(target = nil, config: RailsAgentConsole.configuration, ui: UI.new(config))
|
|
11
|
+
return switch(target, config: config, ui: ui) unless target.nil?
|
|
12
|
+
|
|
13
|
+
report(config, ui)
|
|
14
|
+
return unless Setup.interactive?(config)
|
|
15
|
+
return unless ui.yes?("Change provider, base URL or model?", default: false)
|
|
16
|
+
|
|
17
|
+
Wizard.run(config: config, ui: ui)
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def switch(target, config:, ui:)
|
|
24
|
+
choice = parse(target, config)
|
|
25
|
+
return unknown(target, ui) if choice.nil?
|
|
26
|
+
|
|
27
|
+
before = config.snapshot
|
|
28
|
+
apply(choice, config)
|
|
29
|
+
unless ollama_has_model?(config, ui)
|
|
30
|
+
config.restore(before)
|
|
31
|
+
return
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
typed = config.api_key? ? nil : read_key(config, ui)
|
|
35
|
+
return refuse_without_key(before, config, ui) unless config.api_key?
|
|
36
|
+
|
|
37
|
+
RailsAgentConsole.agent.reset_provider!
|
|
38
|
+
ui.success "Now using #{label(config)}."
|
|
39
|
+
remember(config, ui, typed)
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def apply(choice, config)
|
|
44
|
+
if choice[:provider] != config.provider
|
|
45
|
+
config.provider = choice[:provider]
|
|
46
|
+
config.api_base = nil
|
|
47
|
+
end
|
|
48
|
+
config.model = choice[:model] || stored_model(config.provider) ||
|
|
49
|
+
Configuration::DEFAULT_MODELS[config.provider]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse(target, config)
|
|
53
|
+
text = target.to_s.strip
|
|
54
|
+
return if text.empty?
|
|
55
|
+
|
|
56
|
+
provider, model = text.split("/", 2)
|
|
57
|
+
return { provider: provider.to_sym, model: model } if known?(provider) && !model.to_s.empty?
|
|
58
|
+
return { provider: provider.to_sym, model: nil } if known?(provider) && model.nil?
|
|
59
|
+
return if text.include?("/")
|
|
60
|
+
|
|
61
|
+
{ provider: Configuration.provider_for(text) || config.provider, model: text }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def known?(name)
|
|
65
|
+
Configuration::PROVIDERS.include?(name.to_s.to_sym)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unknown(target, ui)
|
|
69
|
+
ui.failure "#{target.inspect} names no provider. Known: #{Configuration::PROVIDERS.join(", ")}."
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ollama_has_model?(config, ui)
|
|
74
|
+
return true unless config.provider == :ollama
|
|
75
|
+
|
|
76
|
+
installed = Providers::Ollama.new(config).installed_models
|
|
77
|
+
if installed.empty?
|
|
78
|
+
ui.warn "Ollama did not list any models on #{config.api_base || Providers::Ollama::DEFAULT_BASE}. " \
|
|
79
|
+
"Start it with `ollama serve`, and get a model with `ollama pull #{config.model}`."
|
|
80
|
+
return true
|
|
81
|
+
end
|
|
82
|
+
return true if installed.include?(config.model) || installed.include?("#{config.model}:latest")
|
|
83
|
+
|
|
84
|
+
ui.failure "#{config.model} is not installed in Ollama. Installed: #{installed.join(", ")}."
|
|
85
|
+
ui.note " Get it with `ollama pull #{config.model}`, or pick one of those with ai_model \"ollama/NAME\"."
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def read_key(config, ui)
|
|
90
|
+
return unless Setup.interactive?(config)
|
|
91
|
+
|
|
92
|
+
env = Configuration::ENV_KEYS[config.provider]
|
|
93
|
+
ui.note " #{Setup::PROVIDER_LABELS[config.provider]} needs an API key. It is read from #{env}; " \
|
|
94
|
+
"or type it here, it will not be shown."
|
|
95
|
+
key = ui.secret("#{config.provider} API key:")
|
|
96
|
+
return if key.empty?
|
|
97
|
+
|
|
98
|
+
config.api_key = key
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def refuse_without_key(before, config, ui)
|
|
102
|
+
provider = config.provider
|
|
103
|
+
config.restore(before)
|
|
104
|
+
how = Setup.interactive?(config) ? "none was typed" : "run ai_model in a terminal to type one"
|
|
105
|
+
ui.failure "No key for #{provider} (set #{Configuration::ENV_KEYS[provider]}, or #{how}), " \
|
|
106
|
+
"so this stays on #{label(config)}."
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def remember(config, ui, typed_key)
|
|
111
|
+
return unless Setup.interactive?(config)
|
|
112
|
+
return unless ui.yes?("Make #{label(config)} the default in #{Credentials.path}?", default: false)
|
|
113
|
+
|
|
114
|
+
Credentials.store(provider: config.provider, api_key: typed_key, model: config.model)
|
|
115
|
+
ui.success "Saved."
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
ui.failure "Could not write #{Credentials.path}: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def stored_model(provider)
|
|
121
|
+
Credentials.read.dig(provider.to_s, "model")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def report(config, ui)
|
|
125
|
+
ui.blank
|
|
126
|
+
ui.say "#{ui.bold(label(config))} | #{where(config.provider, config)}"
|
|
127
|
+
ui.blank
|
|
128
|
+
Configuration::PROVIDERS.each { |name| ui.say " #{row(name, config)}" }
|
|
129
|
+
ui.blank
|
|
130
|
+
ui.note ' ai_model "gpt-4o" any model; the provider follows from the name'
|
|
131
|
+
ui.note ' ai_model "ollama/qwen2.5-coder:7b" a provider, and its model'
|
|
132
|
+
ui.blank
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def row(name, config)
|
|
136
|
+
current = name == config.provider
|
|
137
|
+
model = current ? config.model : stored_model(name) || Configuration::DEFAULT_MODELS[name]
|
|
138
|
+
"#{current ? "*" : " "} #{name.to_s.ljust(10)} #{model.to_s.ljust(26)} #{where(name, config)}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def where(name, config)
|
|
142
|
+
base = name == config.provider ? config.api_base : Credentials.read.dig(name.to_s, "base_url")
|
|
143
|
+
note = key_note(name, config, base)
|
|
144
|
+
base && name != :ollama ? "#{note}, via #{base}" : note
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def key_note(name, config, base)
|
|
148
|
+
return "runs locally, needs Ollama on #{base || Providers::Ollama::DEFAULT_BASE}" if name == :ollama
|
|
149
|
+
|
|
150
|
+
env = Configuration::ENV_KEYS[name]
|
|
151
|
+
return "key from #{env}" unless ENV.fetch(env, "").strip.empty?
|
|
152
|
+
return "key from #{Credentials.path}" if stored_key?(name)
|
|
153
|
+
|
|
154
|
+
config.client ? "unused: config.client answers instead" : "no key (set #{env})"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def stored_key?(name)
|
|
158
|
+
!Credentials.read.dig(name.to_s, "api_key").to_s.strip.empty?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def label(config)
|
|
162
|
+
"#{config.provider}/#{config.model}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Prompt
|
|
5
|
+
module Dates
|
|
6
|
+
MONTHS = %w[january february march april may june july august september october november december].freeze
|
|
7
|
+
MONTH_YEAR = /\b(#{MONTHS.join("|")})\s+((?:19|20)\d{2})\b/i
|
|
8
|
+
YEAR = %r{(?<![\d./-])((?:19|20)\d{2})(?![\d./-])}
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def annotate(prompt)
|
|
13
|
+
ranges = month_ranges(prompt) + year_ranges(prompt)
|
|
14
|
+
return prompt if ranges.empty?
|
|
15
|
+
|
|
16
|
+
"#{prompt}\n\nDates in this request, as ranges to filter on: #{ranges.join("; ")}."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def month_ranges(prompt)
|
|
20
|
+
prompt.scan(MONTH_YEAR).map do |month, year|
|
|
21
|
+
number = MONTHS.index(month.downcase) + 1
|
|
22
|
+
%(#{month.capitalize} #{year} is Time.zone.parse("#{year}-#{format("%02d", number)}-01").all_month)
|
|
23
|
+
end.uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def year_ranges(prompt)
|
|
27
|
+
within_months = prompt.scan(MONTH_YEAR).map(&:last)
|
|
28
|
+
(prompt.scan(YEAR).flatten.uniq - within_months).map do |year|
|
|
29
|
+
%(#{year} is Time.zone.parse("#{year}-01-01").all_year)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Prompt
|
|
5
|
+
module FollowUp
|
|
6
|
+
BACK_REFERENCE = /\b(them|those|these|they|their|the same|ih|njih|njima|njihov\w*|tih|isto)\b/i
|
|
7
|
+
EARLIER_QUERY = /\b(quer(y|ies)|that|this|last|previous|earlier|you gave|upit\w*|prethodn\w*|zadnj\w*)\b/i
|
|
8
|
+
ASKED_ABOUT = 3
|
|
9
|
+
TERMINALS = %w[count size length sum average minimum maximum pluck ids first last exists? any? to_a].freeze
|
|
10
|
+
TERMINAL = /\.\s*(#{Regexp.union(TERMINALS).source})(?<args>\((?:[^()]|\g<args>)*\))?\s*\z/m
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def annotate(prompt, previous_code)
|
|
15
|
+
base = base_of(previous_code)
|
|
16
|
+
return prompt if base.nil? || !prompt.match?(BACK_REFERENCE)
|
|
17
|
+
|
|
18
|
+
"#{prompt}\n\nThis continues the previous query. Keep its conditions and build on: #{base}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def for_ask(question, history)
|
|
22
|
+
return question if history.empty?
|
|
23
|
+
return question unless question.match?(BACK_REFERENCE) || question.match?(EARLIER_QUERY)
|
|
24
|
+
|
|
25
|
+
recent = history.turns.last(ASKED_ABOUT).map { |turn| "- #{turn.prompt}\n #{turn.code}" }
|
|
26
|
+
"#{question}\n\nQueries run earlier in this console session, oldest first:\n#{recent.join("\n")}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def base_of(code)
|
|
30
|
+
base = code.to_s.strip
|
|
31
|
+
return if base.empty?
|
|
32
|
+
|
|
33
|
+
base = base.sub(TERMINAL, "") while base.match?(TERMINAL)
|
|
34
|
+
base unless base.empty?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Prompt
|
|
5
|
+
QUERY_RULES = <<~RULES
|
|
6
|
+
You translate requests into ActiveRecord code for the application described below.
|
|
7
|
+
|
|
8
|
+
Answer with a single JSON object and nothing else:
|
|
9
|
+
{"code": "<ruby expression>", "explanation": "<1-2 sentences>", "assumptions": ["<optional>"]}
|
|
10
|
+
|
|
11
|
+
Hard rules for "code":
|
|
12
|
+
- Exactly one Ruby expression. Chaining across several lines is fine, `;` and multiple statements are not.
|
|
13
|
+
- No assignments, no `puts`, no comments, no markdown fences.
|
|
14
|
+
- Only use models, columns and associations that appear in the schema below. Never invent a column.
|
|
15
|
+
- Associations are not symmetrical: a model listed as "referenced by" has no inverse association,
|
|
16
|
+
so start the query from the model that owns the belongs_to and reach across it.
|
|
17
|
+
- Aggregate in the database (count/sum/average/group/pluck) instead of loading records into Ruby.
|
|
18
|
+
- Wrap raw SQL fragments passed to order/group/select in Arel.sql("...").
|
|
19
|
+
Quote them with double quotes, so the single quotes SQL needs around its own
|
|
20
|
+
literals do not end the Ruby string: Arel.sql("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END)").
|
|
21
|
+
- A column name is never a Ruby value: comparing two columns happens inside the SQL string,
|
|
22
|
+
as in where("email LIKE '%' || city || '%'"). Written as "%" || city in Ruby it silently
|
|
23
|
+
becomes "%", and the query answers with every row in the table.
|
|
24
|
+
- SQL cannot nest one aggregate inside another, so there is no average of a count.
|
|
25
|
+
Divide two aggregates instead: SearchResult.count.to_f / SearchResult.distinct.count(:customer_id).
|
|
26
|
+
- "show", "list", "display" and "find" ask for the records themselves, so return the relation
|
|
27
|
+
on its own: the console prints it, so it needs no .load, .to_a or trailing .all.
|
|
28
|
+
Reach for count, sum or average only when the request asks how many, or for a total.
|
|
29
|
+
- Return whole records. Only add select(...) when the request names the columns it wants,
|
|
30
|
+
because a partial select raises ActiveRecord::MissingAttributeError on every other attribute.
|
|
31
|
+
- Always finish a group with an aggregate: count, sum, average, minimum, maximum or pluck.
|
|
32
|
+
A bare Model.group(:city) relation cannot even be printed, so it is never the answer.
|
|
33
|
+
- To count rows per related record, group by the column you want to read back and count from the
|
|
34
|
+
model that owns the rows: SearchResult.joins(:brand).group("brands.name").count.
|
|
35
|
+
Never call count on a relation that uses select(...), and never group by :id plus select(...):
|
|
36
|
+
both end up as COUNT(id, name), which is not a function, or as an ambiguous column.
|
|
37
|
+
- group(...) and order(...) take expressions, never aliases, and no AS: one argument per
|
|
38
|
+
column, as in SearchResult.group(Arel.sql("EXTRACT(YEAR FROM created_at)"), :source).count.
|
|
39
|
+
- To sort grouped counts, order by the aggregate itself: Model.group(:city).order(Arel.sql("COUNT(*) DESC")).limit(5).count.
|
|
40
|
+
There is no count_id, count_all or counter column to order by.
|
|
41
|
+
- Use Time.current, Date.current and ActiveSupport durations (30.days.ago, 1.month.ago) instead of Time.now.
|
|
42
|
+
- Beginless/endless ranges are preferred for date filters: where(created_at: 30.days.ago..).
|
|
43
|
+
- When the request refers to "them", "those" or "that", continue from the previous query in this conversation.
|
|
44
|
+
- An empty "code" is the honest answer when the request needs a write, or when the schema really
|
|
45
|
+
has nothing to answer it with. Say why in "explanation", and name the column you looked for.
|
|
46
|
+
Never stand in for an empty answer with Model.none, {} or [], and never invent a column.
|
|
47
|
+
- Reach that conclusion last, though, and only after reading every column above it. A request
|
|
48
|
+
rarely uses the same words as the schema does: "in 2025" is created_at, a "failure" is an error
|
|
49
|
+
column being present, and the column that answers the request is often named something else
|
|
50
|
+
entirely. Almost every request about this application can be answered from these tables.
|
|
51
|
+
- An alias from select(...) exists only in the rows that come back. Nothing else in the query
|
|
52
|
+
can see it: not a block, not where, not group, not pluck, not another expression in the same
|
|
53
|
+
select. Repeat the expression instead, and compute ratios with SQL inside the aggregate.
|
|
54
|
+
RULES
|
|
55
|
+
|
|
56
|
+
READ_ONLY_RULES = <<~RULES
|
|
57
|
+
This console runs in READ-ONLY mode. The generated code is rejected by a static
|
|
58
|
+
validator unless every call is read-only, so never use create, save, update,
|
|
59
|
+
update_all, delete, delete_all, destroy, destroy_all, insert_all, upsert,
|
|
60
|
+
transaction, connection.execute, send, eval or shell commands.
|
|
61
|
+
|
|
62
|
+
Reading is never a write. Returning records, a relation, a count or a group is
|
|
63
|
+
exactly what this mode is for, so a request to show, list or display records is
|
|
64
|
+
always answerable: read-only is never the reason to refuse one.
|
|
65
|
+
RULES
|
|
66
|
+
|
|
67
|
+
WRITE_RULES = <<~RULES
|
|
68
|
+
This console runs in WRITE mode: data-changing code is allowed, but the human
|
|
69
|
+
has to confirm it. Keep the change as narrow as possible, always scope writes
|
|
70
|
+
with an explicit `where`, and never use connection.execute, send, eval or shell
|
|
71
|
+
commands.
|
|
72
|
+
RULES
|
|
73
|
+
|
|
74
|
+
EXPLAIN_RULES = <<~RULES
|
|
75
|
+
You are a senior Rails engineer answering a question in a teammate's console.
|
|
76
|
+
Explain clearly and concretely, in plain prose with short code snippets where
|
|
77
|
+
they help. Reference the real models, columns and associations from the schema
|
|
78
|
+
below. Mention the SQL that ActiveRecord would generate when it is relevant,
|
|
79
|
+
and call out N+1 queries, missing indexes or loaded-vs-lazy relations when you
|
|
80
|
+
see them. Do not ask the user to run anything destructive. Keep it under 200 words
|
|
81
|
+
unless the question really needs more.
|
|
82
|
+
RULES
|
|
83
|
+
|
|
84
|
+
class << self
|
|
85
|
+
def query_system(prompt, write_mode: false)
|
|
86
|
+
[
|
|
87
|
+
QUERY_RULES,
|
|
88
|
+
write_mode ? WRITE_RULES : READ_ONLY_RULES,
|
|
89
|
+
"# Application",
|
|
90
|
+
Schema.context(prompt)
|
|
91
|
+
].join("\n")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def explain_system(prompt)
|
|
95
|
+
[EXPLAIN_RULES, "# Application", Schema.context(prompt)].join("\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def repair_user(prompt, attempts)
|
|
99
|
+
lines = ["These queries were tried for this request and did not work:"]
|
|
100
|
+
Array(attempts).each { |attempt| lines += ["", attempt[:code].to_s, attempt[:reason].to_s] }
|
|
101
|
+
|
|
102
|
+
(lines + [
|
|
103
|
+
"",
|
|
104
|
+
"Answer with a corrected query for the original request: #{prompt}",
|
|
105
|
+
"Do not propose any of the queries above again, and do not guess: check the schema first.",
|
|
106
|
+
"If an error names an association, that association does not exist on that model. " \
|
|
107
|
+
"Start from the model whose belongs_to points at it, as the schema spells out.",
|
|
108
|
+
"If the request cannot be answered from this schema, answer with an empty code."
|
|
109
|
+
]).join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def explain_code_user(code, question)
|
|
113
|
+
parts = ["Explain this ActiveRecord code:", "", code.to_s]
|
|
114
|
+
parts += ["", "The specific question is: #{question}"] if question
|
|
115
|
+
parts.join("\n")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
require_relative "prompt/dates"
|
|
122
|
+
require_relative "prompt/follow_up"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsAgentConsole
|
|
6
|
+
class Proposal
|
|
7
|
+
attr_reader :code, :explanation, :assumptions, :raw, :validation, :corrections
|
|
8
|
+
|
|
9
|
+
def self.parse(raw, write_mode: false)
|
|
10
|
+
payload = extract_json(raw) || { "code" => extract_code_block(raw) }
|
|
11
|
+
code = payload["code"].to_s.strip
|
|
12
|
+
|
|
13
|
+
new(
|
|
14
|
+
code: code,
|
|
15
|
+
explanation: payload["explanation"],
|
|
16
|
+
assumptions: Array(payload["assumptions"]),
|
|
17
|
+
raw: raw,
|
|
18
|
+
write_mode: write_mode
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
NO_OP_TAIL = /\.\s*(?:load|all)\s*\z/
|
|
23
|
+
|
|
24
|
+
BARE_COUNT_TAIL = /\.\s*count\s*(?:\(\s*\))?\s*\z/
|
|
25
|
+
|
|
26
|
+
FENCED = /\A```[a-zA-Z]*[ \t]*\r?\n?(.*?)```\z/m
|
|
27
|
+
|
|
28
|
+
def initialize(code:, explanation: nil, assumptions: [], raw: nil, write_mode: false)
|
|
29
|
+
rewritten = Rewriter.apply(count_all(trim_no_ops(unfence(code))))
|
|
30
|
+
@code = rewritten.code
|
|
31
|
+
@corrections = rewritten.notes
|
|
32
|
+
@explanation = explanation
|
|
33
|
+
@assumptions = assumptions
|
|
34
|
+
@raw = raw
|
|
35
|
+
@validation = QueryValidator.validate(@code, write_mode: write_mode)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def safe?
|
|
39
|
+
@validation.valid?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def destructive?
|
|
43
|
+
@validation.destructive?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def writes?
|
|
47
|
+
@validation.writes?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def violations
|
|
51
|
+
@validation.violations
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def models
|
|
55
|
+
@validation.models
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
NO_CODE = ["", "nil", "null", "none", "n/a", "{}", "[]", "0"].freeze
|
|
59
|
+
|
|
60
|
+
NO_QUERY = /\A[A-Z]\w*(?:::[A-Z]\w*)*\s*\.\s*none\z/
|
|
61
|
+
|
|
62
|
+
def empty?
|
|
63
|
+
NO_CODE.include?(@code.downcase) || NO_QUERY.match?(@code) || statements.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def unfence(code)
|
|
69
|
+
text = code.to_s.strip
|
|
70
|
+
text = Regexp.last_match(1).to_s.strip if FENCED.match(text)
|
|
71
|
+
text.gsub(/\A`+|`+\z/, "").strip
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def trim_no_ops(code)
|
|
75
|
+
loop do
|
|
76
|
+
trimmed = code.sub(NO_OP_TAIL, "")
|
|
77
|
+
break unless trimmed != code && trimmed.include?(".")
|
|
78
|
+
|
|
79
|
+
code = trimmed
|
|
80
|
+
end
|
|
81
|
+
code
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def count_all(code)
|
|
85
|
+
return code unless BARE_COUNT_TAIL.match?(code) && several_selected?(code)
|
|
86
|
+
|
|
87
|
+
code.sub(BARE_COUNT_TAIL, ".count(:all)")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def several_selected?(code)
|
|
91
|
+
code.to_s.enum_for(:scan, /\.\s*select\s*\(/).any? do
|
|
92
|
+
several_columns?(code[Regexp.last_match.end(0)..])
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
Scan = Struct.new(:quote, :sql, :ruby)
|
|
97
|
+
|
|
98
|
+
def several_columns?(arguments)
|
|
99
|
+
scan = Scan.new(nil, 0, 0)
|
|
100
|
+
|
|
101
|
+
arguments.to_s.each_char do |char|
|
|
102
|
+
verdict = scan.quote ? read_in_string(scan, char) : read_in_ruby(scan, char)
|
|
103
|
+
return verdict unless verdict.nil?
|
|
104
|
+
end
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_in_string(scan, char)
|
|
109
|
+
case char
|
|
110
|
+
when scan.quote then scan.quote = nil
|
|
111
|
+
when "(" then scan.sql += 1
|
|
112
|
+
when ")" then scan.sql -= 1 unless scan.sql.zero?
|
|
113
|
+
when "," then return true if scan.sql.zero?
|
|
114
|
+
end
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_in_ruby(scan, char)
|
|
119
|
+
case char
|
|
120
|
+
when "'", '"' then scan.quote = char
|
|
121
|
+
when "(" then scan.ruby += 1
|
|
122
|
+
when ")"
|
|
123
|
+
return false if scan.ruby.zero? # the select call ends here
|
|
124
|
+
|
|
125
|
+
scan.ruby -= 1
|
|
126
|
+
when "," then return true if scan.sql.zero?
|
|
127
|
+
end
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def statements
|
|
132
|
+
@code.lines.map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class << self
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def extract_json(raw)
|
|
139
|
+
candidate = balanced_object(strip_fences(raw.to_s.strip))
|
|
140
|
+
return unless candidate
|
|
141
|
+
|
|
142
|
+
parse_json(candidate) || salvage(candidate)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def strip_fences(text)
|
|
146
|
+
text.gsub(/\A```[a-zA-Z]*[ \t]*\r?\n?/, "").gsub(/```\z/, "").strip
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def balanced_object(text)
|
|
150
|
+
start = text.index("{")
|
|
151
|
+
return unless start
|
|
152
|
+
|
|
153
|
+
depth = 0
|
|
154
|
+
in_string = false
|
|
155
|
+
escaped = false
|
|
156
|
+
text[start..].each_char.with_index do |char, index|
|
|
157
|
+
if in_string
|
|
158
|
+
if escaped then escaped = false
|
|
159
|
+
elsif char == "\\" then escaped = true
|
|
160
|
+
elsif char == '"' then in_string = false
|
|
161
|
+
end
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
case char
|
|
166
|
+
when '"' then in_string = true
|
|
167
|
+
when "{" then depth += 1
|
|
168
|
+
when "}"
|
|
169
|
+
depth -= 1
|
|
170
|
+
return text[start, index + 1] if depth.zero?
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def salvage(text)
|
|
177
|
+
code = json_string(text, "code")
|
|
178
|
+
return if code.nil?
|
|
179
|
+
|
|
180
|
+
{ "code" => code, "explanation" => json_string(text, "explanation") }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def json_string(text, key)
|
|
184
|
+
raw = text[/"#{key}"\s*:\s*"((?:[^"\\]|\\.)*)"/, 1]
|
|
185
|
+
return if raw.nil?
|
|
186
|
+
|
|
187
|
+
JSON.parse("\"#{raw}\"")
|
|
188
|
+
rescue JSON::ParserError
|
|
189
|
+
raw
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def parse_json(candidate)
|
|
193
|
+
payload = JSON.parse(candidate)
|
|
194
|
+
payload if payload.is_a?(Hash) && payload.key?("code")
|
|
195
|
+
rescue JSON::ParserError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def extract_code_block(raw)
|
|
200
|
+
text = raw.to_s
|
|
201
|
+
fenced = text[/```[a-zA-Z]*[ \t]*\r?\n(.*?)```/m, 1]
|
|
202
|
+
return fenced.strip if fenced
|
|
203
|
+
|
|
204
|
+
text.strip
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Providers
|
|
5
|
+
class Anthropic < Base
|
|
6
|
+
DEFAULT_BASE = "https://api.anthropic.com/v1"
|
|
7
|
+
API_VERSION = "2023-06-01"
|
|
8
|
+
|
|
9
|
+
def name
|
|
10
|
+
"Anthropic"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def models
|
|
14
|
+
listed = get("#{base_url(DEFAULT_BASE)}/models?limit=100", headers)
|
|
15
|
+
Array(listed&.dig("data")).filter_map { |entry| entry["id"] }
|
|
16
|
+
rescue ConfigurationError
|
|
17
|
+
[]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def endpoint
|
|
23
|
+
"#{base_url(DEFAULT_BASE)}/messages"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def headers
|
|
27
|
+
super.merge("x-api-key" => api_key!, "anthropic-version" => API_VERSION)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def payload(system:, messages:)
|
|
31
|
+
{
|
|
32
|
+
model: model,
|
|
33
|
+
system: system,
|
|
34
|
+
max_tokens: 1024,
|
|
35
|
+
temperature: config.temperature,
|
|
36
|
+
messages: messages
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract(response)
|
|
41
|
+
content = Array(response["content"]).filter_map { |part| part["text"] }.join.strip
|
|
42
|
+
raise InvalidResponseError, "Anthropic returned no content" if content.empty?
|
|
43
|
+
|
|
44
|
+
content
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|