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,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