glancer 1.0.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/.github/workflows/ci.yml +96 -0
- data/.rubocop.yml +54 -0
- data/CHANGELOG.md +88 -0
- data/CLAUDE.md +115 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +354 -0
- data/app/assets/config/glancer_manifest.js +1 -0
- data/app/assets/javascripts/glancer/application.js +15 -0
- data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
- data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
- data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
- data/app/assets/stylesheets/glancer/application.css +350 -0
- data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
- data/app/assets/stylesheets/glancer/list.css +31 -0
- data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
- data/app/assets/stylesheets/glancer/table.css +97 -0
- data/app/controllers/glancer/application_controller.rb +33 -0
- data/app/controllers/glancer/chats_controller.rb +49 -0
- data/app/controllers/glancer/messages_controller.rb +144 -0
- data/app/controllers/glancer/schema_controller.rb +29 -0
- data/app/controllers/glancer/settings_controller.rb +23 -0
- data/app/helpers/glancer/application_helper.rb +17 -0
- data/app/jobs/glancer/application_job.rb +6 -0
- data/app/jobs/glancer/process_message_job.rb +38 -0
- data/app/models/glancer/audit.rb +12 -0
- data/app/models/glancer/chat.rb +8 -0
- data/app/models/glancer/code_version.rb +12 -0
- data/app/models/glancer/embedding.rb +6 -0
- data/app/models/glancer/message.rb +25 -0
- data/app/models/glancer/setting.rb +23 -0
- data/app/models/glancer/sql_version.rb +6 -0
- data/app/views/glancer/_data/_importmap.json.erb +7 -0
- data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
- data/app/views/glancer/chats/_show.html.erb +52 -0
- data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
- data/app/views/glancer/chats/index.html.erb +10 -0
- data/app/views/glancer/chats/show.html.erb +1 -0
- data/app/views/glancer/messages/_data_table.html.erb +268 -0
- data/app/views/glancer/messages/_execution_error.html.erb +26 -0
- data/app/views/glancer/messages/_form.html.erb +93 -0
- data/app/views/glancer/messages/_message.html.erb +206 -0
- data/app/views/glancer/messages/_message_info.html.erb +176 -0
- data/app/views/glancer/messages/_temp_form.html.erb +100 -0
- data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
- data/app/views/glancer/schema/show.html.erb +123 -0
- data/app/views/glancer/settings/show.html.erb +306 -0
- data/app/views/glancer/shared/_icons.html.erb +126 -0
- data/app/views/layouts/glancer/application.html.erb +234 -0
- data/config/locales/glancer.en.yml +90 -0
- data/config/locales/glancer.es.yml +90 -0
- data/config/locales/glancer.pt-BR.yml +90 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
- data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
- data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
- data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
- data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
- data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
- data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
- data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
- data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
- data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
- data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
- data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
- data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
- data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
- data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
- data/lib/generators/glancer/install/install_generator.rb +74 -0
- data/lib/generators/glancer/install/templates/glancer.rb +227 -0
- data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
- data/lib/glancer/async_runner.rb +50 -0
- data/lib/glancer/chart_analyzer.rb +230 -0
- data/lib/glancer/configuration.rb +372 -0
- data/lib/glancer/engine.rb +90 -0
- data/lib/glancer/indexer/context_indexer.rb +58 -0
- data/lib/glancer/indexer/model_indexer.rb +64 -0
- data/lib/glancer/indexer/schema_indexer.rb +171 -0
- data/lib/glancer/indexer.rb +50 -0
- data/lib/glancer/retriever.rb +114 -0
- data/lib/glancer/utils/logger.rb +83 -0
- data/lib/glancer/utils/markdown_helper.rb +56 -0
- data/lib/glancer/utils/result_formatter.rb +25 -0
- data/lib/glancer/utils/table_stats.rb +18 -0
- data/lib/glancer/utils/transaction.rb +59 -0
- data/lib/glancer/version.rb +5 -0
- data/lib/glancer/workflow/ar_executor.rb +104 -0
- data/lib/glancer/workflow/ar_extractor.rb +25 -0
- data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
- data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
- data/lib/glancer/workflow/builder.rb +129 -0
- data/lib/glancer/workflow/cache.rb +55 -0
- data/lib/glancer/workflow/executor.rb +72 -0
- data/lib/glancer/workflow/llm.rb +123 -0
- data/lib/glancer/workflow/prompt_builder.rb +143 -0
- data/lib/glancer/workflow/query_enricher.rb +117 -0
- data/lib/glancer/workflow/sql_extractor.rb +42 -0
- data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
- data/lib/glancer/workflow/sql_validator.rb +67 -0
- data/lib/glancer/workflow.rb +158 -0
- data/lib/glancer.rb +50 -0
- data/lib/tasks/glancer/tailwind.rake +8 -0
- data/lib/tasks/glancer.rake +99 -0
- data/spec/glancer_spec.rb +62 -0
- data/spec/lib/glancer/async_runner_spec.rb +133 -0
- data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
- data/spec/lib/glancer/configuration_spec.rb +858 -0
- data/spec/lib/glancer/engine_spec.rb +209 -0
- data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
- data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
- data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
- data/spec/lib/glancer/indexer_spec.rb +95 -0
- data/spec/lib/glancer/retriever_spec.rb +179 -0
- data/spec/lib/glancer/utils/logger_spec.rb +85 -0
- data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
- data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
- data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
- data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
- data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
- data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
- data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
- data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
- data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
- data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
- data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
- data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
- data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
- data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
- data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
- data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
- data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
- data/spec/lib/glancer/workflow_spec.rb +308 -0
- data/spec/models/glancer/audit_spec.rb +82 -0
- data/spec/models/glancer/chat_spec.rb +60 -0
- data/spec/models/glancer/code_version_spec.rb +71 -0
- data/spec/models/glancer/embedding_spec.rb +73 -0
- data/spec/models/glancer/message_spec.rb +144 -0
- data/spec/models/glancer/setting_spec.rb +88 -0
- data/spec/models/glancer/sql_version_spec.rb +4 -0
- data/spec/spec_helper.rb +128 -0
- data/spec/support/schema.rb +55 -0
- metadata +255 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module Utils
|
|
5
|
+
class Transaction
|
|
6
|
+
def self.make(&block)
|
|
7
|
+
original_config = ActiveRecord::Base.connection_db_config
|
|
8
|
+
connection = read_only_connection || ActiveRecord::Base.connection
|
|
9
|
+
|
|
10
|
+
Glancer::Utils::Logger.info("Utils::Transaction",
|
|
11
|
+
"Using \e[1;33m#{connection_config_name(connection)}\e[36m connection for query execution.")
|
|
12
|
+
|
|
13
|
+
connection.transaction { yield(connection) }
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
Glancer::Utils::Logger.error("Utils::Transaction", "An error occurred: #{e.message}")
|
|
16
|
+
raise
|
|
17
|
+
ensure
|
|
18
|
+
ActiveRecord::Base.establish_connection(original_config) if read_only_connection_used?
|
|
19
|
+
|
|
20
|
+
if defined?(connection) && connection&.transaction_open?
|
|
21
|
+
Glancer::Utils::Logger.warn("Utils::Transaction",
|
|
22
|
+
"Transaction was not closed properly. Please check your code.")
|
|
23
|
+
else
|
|
24
|
+
Glancer::Utils::Logger.info("Utils::Transaction", "Transaction completed successfully.")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.read_only_connection
|
|
29
|
+
return nil unless Glancer.configuration.read_only_db
|
|
30
|
+
|
|
31
|
+
Glancer::Utils::Logger.info("Utils::Transaction", "Establishing connection to read-only database...")
|
|
32
|
+
|
|
33
|
+
@used_read_only = true
|
|
34
|
+
|
|
35
|
+
connection = ActiveRecord::Base.establish_connection(Glancer.configuration.read_only_db).connection
|
|
36
|
+
|
|
37
|
+
Glancer::Utils::Logger.info("Utils::Transaction", "Read-only database connection established successfully.")
|
|
38
|
+
connection
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Glancer::Utils::Logger.error("Utils::Transaction",
|
|
41
|
+
"Failed to connect to read-only database: #{e.class} - #{e.message}")
|
|
42
|
+
Glancer::Utils::Logger.debug("Utils::Transaction", "Backtrace:\n#{e.backtrace.join("\n")}")
|
|
43
|
+
raise Glancer::Error, "Read-only DB connection failed: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.read_only_connection_used?
|
|
47
|
+
value = @used_read_only
|
|
48
|
+
@used_read_only = false # reset
|
|
49
|
+
value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.connection_config_name(connection)
|
|
53
|
+
connection.pool.db_config.name
|
|
54
|
+
rescue StandardError
|
|
55
|
+
"unknown"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module Workflow
|
|
5
|
+
class ARExecutor
|
|
6
|
+
def self.execute(code, original_question: nil, attempt: 1, message_id: nil)
|
|
7
|
+
Glancer::Utils::Logger.info("Workflow::ARExecutor", "Executing AR expression (Attempt ##{attempt})...")
|
|
8
|
+
|
|
9
|
+
run_id = SecureRandom.uuid
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
result = nil
|
|
13
|
+
Glancer::Utils::Transaction.make do |connection|
|
|
14
|
+
Glancer::Workflow::Executor.apply_statement_timeout(connection)
|
|
15
|
+
raw = evaluate(code)
|
|
16
|
+
result = normalize(raw)
|
|
17
|
+
raise ActiveRecord::Rollback
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Glancer::Audit.create!(
|
|
21
|
+
question: original_question,
|
|
22
|
+
code: code,
|
|
23
|
+
code_type: "activerecord",
|
|
24
|
+
adapter: Glancer.configuration.resolved_adapter,
|
|
25
|
+
run_id: run_id,
|
|
26
|
+
executed_at: Time.current,
|
|
27
|
+
message_id: message_id
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
result
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
if attempt >= 3
|
|
33
|
+
Glancer::Utils::Logger.error("Workflow::ARExecutor",
|
|
34
|
+
"Final failure after #{attempt} attempts: #{e.message}")
|
|
35
|
+
return { error: true, message: e.message, last_code: code }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Glancer::Utils::Logger.warn("Workflow::ARExecutor",
|
|
39
|
+
"AR Error (Attempt ##{attempt}): #{e.message}. Requesting correction...")
|
|
40
|
+
|
|
41
|
+
fixed_code = Glancer::Workflow::Builder.fix_ar_code(code, e.message)
|
|
42
|
+
execute(fixed_code, original_question: original_question, attempt: attempt + 1, message_id: message_id)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.evaluate(code)
|
|
47
|
+
TOPLEVEL_BINDING.eval(code)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.normalize(result)
|
|
51
|
+
rows = case result
|
|
52
|
+
when ActiveRecord::Relation
|
|
53
|
+
result.to_a.map { |r| r.respond_to?(:attributes) ? r.attributes : { "value" => r } }
|
|
54
|
+
when Array
|
|
55
|
+
result.map do |item|
|
|
56
|
+
if item.respond_to?(:attributes)
|
|
57
|
+
item.attributes
|
|
58
|
+
elsif item.is_a?(Hash)
|
|
59
|
+
item.stringify_keys
|
|
60
|
+
else
|
|
61
|
+
{ "value" => item }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
when Hash
|
|
65
|
+
return normalize_hash(result.stringify_keys)
|
|
66
|
+
when Numeric, String
|
|
67
|
+
return [{ "result" => result }]
|
|
68
|
+
when NilClass
|
|
69
|
+
return []
|
|
70
|
+
else
|
|
71
|
+
# Single AR model object (e.g. from .first / .find)
|
|
72
|
+
return [{ "result" => result.inspect }] unless result.respond_to?(:attributes)
|
|
73
|
+
|
|
74
|
+
[result.attributes]
|
|
75
|
+
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
drop_all_nil_columns(rows)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Removes columns where every row has a nil value (e.g. `id` when using
|
|
82
|
+
# .select("col1, col2") — AR still populates id: nil on the model objects).
|
|
83
|
+
def self.drop_all_nil_columns(rows)
|
|
84
|
+
return rows if rows.empty?
|
|
85
|
+
|
|
86
|
+
nil_cols = rows.first.keys.select { |k| rows.all? { |r| r[k].nil? } }
|
|
87
|
+
return rows if nil_cols.empty?
|
|
88
|
+
|
|
89
|
+
rows.map { |r| r.except(*nil_cols) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Hashes from .group().count/sum/etc. map {group_value => aggregate} and must
|
|
93
|
+
# be rendered as rows. Hashes where values are mixed types (e.g. model
|
|
94
|
+
# attributes) are kept as a single row.
|
|
95
|
+
def self.normalize_hash(hash)
|
|
96
|
+
if hash.values.all? { |val| val.is_a?(Numeric) }
|
|
97
|
+
hash.map { |key, val| { "key" => key.to_s, "value" => val } }
|
|
98
|
+
else
|
|
99
|
+
[hash]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module Workflow
|
|
5
|
+
class ARExtractor
|
|
6
|
+
def self.extract(text)
|
|
7
|
+
Glancer::Utils::Logger.info("Workflow::ARExtractor", "Extracting Ruby expression from LLM response...")
|
|
8
|
+
|
|
9
|
+
code = if text =~ /```(?:ruby)?\s*\n?(.*?)\s*```/mi
|
|
10
|
+
Glancer::Utils::Logger.debug("Workflow::ARExtractor", "Extracted from code block.")
|
|
11
|
+
::Regexp.last_match(1).strip
|
|
12
|
+
else
|
|
13
|
+
Glancer::Utils::Logger.debug("Workflow::ARExtractor", "No code block found, using raw text.")
|
|
14
|
+
text.strip
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Glancer::Utils::Logger.debug("Workflow::ARExtractor", "Extracted expression:\n#{code}")
|
|
18
|
+
code
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
Glancer::Utils::Logger.error("Workflow::ARExtractor", "Extraction failed: #{e.message}")
|
|
21
|
+
raise Glancer::Error, "AR code extraction failed: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module Workflow
|
|
5
|
+
class ARPromptBuilder
|
|
6
|
+
def self.custom_instructions_block
|
|
7
|
+
custom = Glancer::Setting.get("custom_instructions")
|
|
8
|
+
custom.present? ? "CUSTOM RULES — MUST BE FOLLOWED STRICTLY:\n#{custom}\n" : ""
|
|
9
|
+
rescue StandardError
|
|
10
|
+
""
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.call(question, embeddings, history: [])
|
|
14
|
+
Glancer::Utils::Logger.info("Workflow::ARPromptBuilder", "Building AR prompt for: #{question.inspect}")
|
|
15
|
+
|
|
16
|
+
now = Time.current.strftime("%Y-%m-%d %H:%M:%S")
|
|
17
|
+
|
|
18
|
+
history_context = history.map do |msg|
|
|
19
|
+
if msg.role == "assistant" && msg.code.present?
|
|
20
|
+
"ASSISTANT (Ruby expression used): #{msg.code.strip}\nASSISTANT (response): #{msg.content}"
|
|
21
|
+
else
|
|
22
|
+
"#{msg.role.upcase}: #{msg.content}"
|
|
23
|
+
end
|
|
24
|
+
end.join("\n\n")
|
|
25
|
+
|
|
26
|
+
schema_context = embeddings.map { |e| e.content.strip }.join("\n\n")
|
|
27
|
+
|
|
28
|
+
<<~PROMPT
|
|
29
|
+
Current datetime: #{now}
|
|
30
|
+
|
|
31
|
+
You are a Ruby on Rails ActiveRecord expert.
|
|
32
|
+
Your ONLY task is to generate a Ruby expression that reads data from the database using ActiveRecord.
|
|
33
|
+
|
|
34
|
+
STRICT GUIDELINES:
|
|
35
|
+
1. **Output**: Return ONLY the Ruby expression, optionally inside a ```ruby code block. No prose.
|
|
36
|
+
2. **Read-only**: Only use read methods: .where, .joins, .includes, .select, .count, .sum, .average,
|
|
37
|
+
.minimum, .maximum, .group, .order, .limit, .offset, .pluck, .first, .last, .all, .find, .find_by,
|
|
38
|
+
.having, .distinct, .reorder, .references, .eager_load, .preload.
|
|
39
|
+
3. **Forbidden**: NEVER call .destroy, .destroy_all, .delete, .delete_all, .update, .update_all,
|
|
40
|
+
.save, .save!, .create, .create!, .insert, .upsert, .touch, or any write method.
|
|
41
|
+
4. **Model names**: Use EXACT class names as they appear in the DATABASE CONTEXT (e.g., User, Order).
|
|
42
|
+
5. **Result**: The expression must return an ActiveRecord::Relation, Array, Hash, Numeric, or String.
|
|
43
|
+
6. **Aliases**: When using .select with raw columns, use AS aliases for clarity.
|
|
44
|
+
7. **Single expression**: Prefer a single-line expression. Multi-line is allowed if necessary.
|
|
45
|
+
|
|
46
|
+
DATABASE CONTEXT:
|
|
47
|
+
#{schema_context}
|
|
48
|
+
|
|
49
|
+
CONVERSATION HISTORY:
|
|
50
|
+
#{history_context.presence || "(no prior messages)"}
|
|
51
|
+
|
|
52
|
+
#{custom_instructions_block}
|
|
53
|
+
NEW QUESTION:
|
|
54
|
+
#{question}
|
|
55
|
+
|
|
56
|
+
OUTPUT RUBY EXPRESSION ONLY:
|
|
57
|
+
PROMPT
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
Glancer::Utils::Logger.error("Workflow::ARPromptBuilder", "Failed: #{e.message}")
|
|
60
|
+
raise Glancer::Error, "AR prompt construction failed: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module Workflow
|
|
5
|
+
class ARSanitizer
|
|
6
|
+
# Destructive ActiveRecord methods
|
|
7
|
+
FORBIDDEN_AR_METHODS = %w[
|
|
8
|
+
destroy destroy_all
|
|
9
|
+
delete delete_all
|
|
10
|
+
update update! update_all update_columns update_column
|
|
11
|
+
save save!
|
|
12
|
+
create create! create_or_find_by create_or_find_by!
|
|
13
|
+
insert insert_all insert_all!
|
|
14
|
+
upsert upsert_all
|
|
15
|
+
touch toggle! increment! decrement! increment_counter decrement_counter
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# Shell / OS execution
|
|
19
|
+
SHELL_PATTERNS = [
|
|
20
|
+
/\bsystem\s*[\[(]/,
|
|
21
|
+
/`[^`]+`/,
|
|
22
|
+
/\bexec\s*\(/,
|
|
23
|
+
/\bspawn\s*\(/,
|
|
24
|
+
/\bOpen3\b/,
|
|
25
|
+
/\bIO\.popen\b/,
|
|
26
|
+
/\bKernel\s*\.\s*system\b/
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Dynamic code execution
|
|
30
|
+
EVAL_PATTERNS = [
|
|
31
|
+
/\beval\s*\(/,
|
|
32
|
+
/\binstance_eval\b/,
|
|
33
|
+
/\bclass_eval\b/,
|
|
34
|
+
/\bmodule_eval\b/,
|
|
35
|
+
/\bBinding\b/
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# File system writes
|
|
39
|
+
FILE_WRITE_PATTERNS = [
|
|
40
|
+
/\bFileUtils\b/,
|
|
41
|
+
/\bFile\s*\.\s*(?:write|binwrite|open)\b/,
|
|
42
|
+
/\bIO\s*\.\s*(?:write|binwrite)\b/
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# Dynamic loading
|
|
46
|
+
LOAD_PATTERNS = [
|
|
47
|
+
/\brequire(?:_relative)?\s*["'(]/,
|
|
48
|
+
/\bload\s*["'(]/,
|
|
49
|
+
/\bautoload\b/
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
def self.ensure_safe!(code)
|
|
53
|
+
Glancer::Utils::Logger.info("Workflow::ARSanitizer", "Sanitizing ActiveRecord expression...")
|
|
54
|
+
|
|
55
|
+
FORBIDDEN_AR_METHODS.each do |method|
|
|
56
|
+
# \b after the method name ensures we don't block e.g. .creates_table or .destroy_later
|
|
57
|
+
if code.match?(/\.#{Regexp.escape(method)}\b/)
|
|
58
|
+
raise Glancer::Error,
|
|
59
|
+
"ActiveRecord expression blocked: forbidden write method '.#{method}'"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
SHELL_PATTERNS.each do |pattern|
|
|
64
|
+
raise Glancer::Error, "ActiveRecord expression blocked: shell execution detected" if code.match?(pattern)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
EVAL_PATTERNS.each do |pattern|
|
|
68
|
+
raise Glancer::Error, "ActiveRecord expression blocked: dynamic eval detected" if code.match?(pattern)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
FILE_WRITE_PATTERNS.each do |pattern|
|
|
72
|
+
raise Glancer::Error, "ActiveRecord expression blocked: file write detected" if code.match?(pattern)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
LOAD_PATTERNS.each do |pattern|
|
|
76
|
+
raise Glancer::Error, "ActiveRecord expression blocked: dynamic load detected" if code.match?(pattern)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Glancer::Utils::Logger.info("Workflow::ARSanitizer", "Expression passed sanitization check.")
|
|
80
|
+
rescue Glancer::Error
|
|
81
|
+
raise
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
Glancer::Utils::Logger.error("Workflow::ARSanitizer", "Sanitization failed: #{e.message}")
|
|
84
|
+
raise Glancer::Error, "AR sanitization failed: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module Workflow
|
|
5
|
+
class Builder
|
|
6
|
+
def self.build_sql(question, embeddings, history: [])
|
|
7
|
+
Glancer::Utils::Logger.info("Workflow::Builder", "Generating SQL from question: #{question.inspect}")
|
|
8
|
+
|
|
9
|
+
prompt = Glancer::Workflow::PromptBuilder.call(
|
|
10
|
+
question, embeddings, history: history, few_shot_examples: recent_examples
|
|
11
|
+
)
|
|
12
|
+
Glancer::Utils::Logger.debug("Workflow::Builder", "Generated prompt for SQL generation:\n#{prompt}")
|
|
13
|
+
|
|
14
|
+
chat = RubyLLM.chat(
|
|
15
|
+
provider: Glancer.configuration.resolved_code_provider,
|
|
16
|
+
model: Glancer.configuration.resolved_code_model,
|
|
17
|
+
assume_model_exists: true
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
response = chat.ask(prompt)
|
|
21
|
+
|
|
22
|
+
Glancer::Utils::Logger.info("Workflow::Builder",
|
|
23
|
+
"LLM responded with SQL (length: #{response.content&.length || 0} characters)")
|
|
24
|
+
|
|
25
|
+
response.content
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Glancer::Utils::Logger.error("Workflow::Builder", "Failed to generate SQL: #{e.class} - #{e.message}")
|
|
28
|
+
Glancer::Utils::Logger.debug("Workflow::Builder", "Backtrace:\n#{e.backtrace.join("\n")}")
|
|
29
|
+
raise Glancer::Error, "code generation failed: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.recent_examples
|
|
33
|
+
Glancer::Audit
|
|
34
|
+
.where(adapter: Glancer.configuration.resolved_adapter.to_s)
|
|
35
|
+
.where.not(question: [nil, ""])
|
|
36
|
+
.order(executed_at: :desc)
|
|
37
|
+
.limit(3)
|
|
38
|
+
.pluck(:question, :code)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.fix_sql(failed_sql, error_message)
|
|
44
|
+
Glancer::Utils::Logger.info("Workflow::Builder", "Attempting to fix failed SQL...")
|
|
45
|
+
|
|
46
|
+
prompt = <<~PROMPT
|
|
47
|
+
The following SQL query failed to execute:
|
|
48
|
+
```sql
|
|
49
|
+
#{failed_sql}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The database returned the following error message:
|
|
53
|
+
"#{error_message}"
|
|
54
|
+
|
|
55
|
+
Your task is to correct the SQL query so it becomes valid for the #{Glancer.configuration.resolved_adapter.to_s.upcase} adapter.
|
|
56
|
+
- Return ONLY the corrected SQL.
|
|
57
|
+
- Do not provide explanations or comments.
|
|
58
|
+
- Ensure it remains a safe SELECT statement.
|
|
59
|
+
PROMPT
|
|
60
|
+
|
|
61
|
+
chat = RubyLLM.chat(
|
|
62
|
+
provider: Glancer.configuration.resolved_code_provider,
|
|
63
|
+
model: Glancer.configuration.resolved_code_model,
|
|
64
|
+
assume_model_exists: true
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
response = chat.ask(prompt)
|
|
68
|
+
|
|
69
|
+
# Clean the response to ensure we only have the raw SQL
|
|
70
|
+
Glancer::Workflow::SQLExtractor.extract(response.content)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Glancer::Utils::Logger.error("Workflow::Builder", "Failed to fix SQL: #{e.message}")
|
|
73
|
+
raise Glancer::Error, "code correction workflow failed: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.build_ar_code(question, embeddings, history: [])
|
|
77
|
+
Glancer::Utils::Logger.info("Workflow::Builder", "Generating AR code from question: #{question.inspect}")
|
|
78
|
+
|
|
79
|
+
prompt = Glancer::Workflow::ARPromptBuilder.call(question, embeddings, history: history)
|
|
80
|
+
|
|
81
|
+
chat = RubyLLM.chat(
|
|
82
|
+
provider: Glancer.configuration.resolved_code_provider,
|
|
83
|
+
model: Glancer.configuration.resolved_code_model,
|
|
84
|
+
assume_model_exists: true
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
response = chat.ask(prompt)
|
|
88
|
+
Glancer::Utils::Logger.info("Workflow::Builder",
|
|
89
|
+
"LLM responded with AR code (length: #{response.content&.length || 0} chars)")
|
|
90
|
+
response.content
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
Glancer::Utils::Logger.error("Workflow::Builder", "Failed to generate AR code: #{e.class} - #{e.message}")
|
|
93
|
+
raise Glancer::Error, "AR code generation failed: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.fix_ar_code(failed_code, error_message)
|
|
97
|
+
Glancer::Utils::Logger.info("Workflow::Builder", "Attempting to fix failed AR expression...")
|
|
98
|
+
|
|
99
|
+
prompt = <<~PROMPT
|
|
100
|
+
The following Ruby/ActiveRecord expression failed to execute:
|
|
101
|
+
```ruby
|
|
102
|
+
#{failed_code}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The error returned was:
|
|
106
|
+
"#{error_message}"
|
|
107
|
+
|
|
108
|
+
Your task is to correct the Ruby expression so it becomes valid and read-only.
|
|
109
|
+
- Return ONLY the corrected Ruby expression (optionally in a ```ruby block).
|
|
110
|
+
- Do not provide explanations or comments.
|
|
111
|
+
- Ensure it uses only read methods (where, joins, select, count, sum, pluck, etc.).
|
|
112
|
+
- NEVER use .destroy, .delete, .update, .save, .create, or any write method.
|
|
113
|
+
PROMPT
|
|
114
|
+
|
|
115
|
+
chat = RubyLLM.chat(
|
|
116
|
+
provider: Glancer.configuration.resolved_code_provider,
|
|
117
|
+
model: Glancer.configuration.resolved_code_model,
|
|
118
|
+
assume_model_exists: true
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
response = chat.ask(prompt)
|
|
122
|
+
Glancer::Workflow::ARExtractor.extract(response.content)
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
Glancer::Utils::Logger.error("Workflow::Builder", "Failed to fix AR code: #{e.message}")
|
|
125
|
+
raise Glancer::Error, "AR code correction failed: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module Workflow
|
|
5
|
+
class Cache
|
|
6
|
+
@@store = {} # Using a simple hash for in-memory storage (@TODO redis / DB - i don't know now)
|
|
7
|
+
|
|
8
|
+
def self.fetch(question)
|
|
9
|
+
Glancer::Utils::Logger.info("Workflow::Cache", "Attempting to fetch cache for question: #{question.inspect}")
|
|
10
|
+
|
|
11
|
+
entry = @@store[question]
|
|
12
|
+
return nil unless entry
|
|
13
|
+
|
|
14
|
+
if expired?(entry)
|
|
15
|
+
Glancer::Utils::Logger.info("Workflow::Cache", "Cache entry expired for question: #{question.inspect}")
|
|
16
|
+
@@store.delete(question)
|
|
17
|
+
return nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Glancer::Utils::Logger.info("Workflow::Cache", "Cache hit for question: #{question.inspect}")
|
|
21
|
+
Glancer::Utils::Logger.debug("Workflow::Cache", "Cached at: #{entry[:cached_at]}")
|
|
22
|
+
entry
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Glancer::Utils::Logger.error("Workflow::Cache", "Failed to fetch from cache: #{e.class} - #{e.message}")
|
|
25
|
+
Glancer::Utils::Logger.debug("Workflow::Cache", "Backtrace:\n#{e.backtrace.join("\n")}")
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.write(question, result)
|
|
30
|
+
Glancer::Utils::Logger.info("Workflow::Cache", "Writing result to cache for question: #{question.inspect}")
|
|
31
|
+
@@store[question] = result.merge(cached_at: Time.current)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Glancer::Utils::Logger.error("Workflow::Cache", "Failed to write to cache: #{e.class} - #{e.message}")
|
|
34
|
+
Glancer::Utils::Logger.debug("Workflow::Cache", "Backtrace:\n#{e.backtrace.join("\n")}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.clear
|
|
38
|
+
Glancer::Utils::Logger.info("Workflow::Cache", "Clearing all cache entries")
|
|
39
|
+
@@store.clear
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Glancer::Utils::Logger.error("Workflow::Cache", "Failed to clear cache: #{e.class} - #{e.message}")
|
|
42
|
+
Glancer::Utils::Logger.debug("Workflow::Cache", "Backtrace:\n#{e.backtrace.join("\n")}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.expired?(entry)
|
|
46
|
+
ttl = Glancer.configuration&.workflow_cache_ttl || 5.minutes
|
|
47
|
+
age = Time.current - entry[:cached_at]
|
|
48
|
+
Glancer::Utils::Logger.debug("Workflow::Cache", "Checking expiration: age=#{age.round(2)}s, ttl=#{ttl.inspect}")
|
|
49
|
+
age > ttl
|
|
50
|
+
rescue StandardError
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module Workflow
|
|
5
|
+
class Executor
|
|
6
|
+
def self.execute(sql, original_question: nil, attempt: 1, message_id: nil)
|
|
7
|
+
# Security check: Ensure only read queries are executed (SELECT or CTEs starting with WITH)
|
|
8
|
+
unless sql.strip.match?(/\A\s*(select|with)\b/i)
|
|
9
|
+
Glancer::Utils::Logger.error("Workflow::Executor", "Blocked attempt to run non-SELECT SQL.")
|
|
10
|
+
raise Glancer::Error, "Only SELECT queries are allowed for execution."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Glancer::Utils::Logger.info("Workflow::Executor", "Executing SQL (Attempt ##{attempt})...")
|
|
14
|
+
|
|
15
|
+
run_id = SecureRandom.uuid
|
|
16
|
+
# Appending a comment for easier database auditing
|
|
17
|
+
sql_with_comment = "#{sql.strip} /*glancer,run_id:#{run_id}*/"
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
result = nil
|
|
21
|
+
Glancer::Utils::Transaction.make do |connection|
|
|
22
|
+
apply_statement_timeout(connection)
|
|
23
|
+
result = connection.exec_query(sql_with_comment).to_a
|
|
24
|
+
raise ActiveRecord::Rollback
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Audit successful execution
|
|
28
|
+
Glancer::Audit.create!(
|
|
29
|
+
question: original_question,
|
|
30
|
+
code: sql_with_comment,
|
|
31
|
+
code_type: "sql",
|
|
32
|
+
adapter: Glancer.configuration.resolved_adapter,
|
|
33
|
+
run_id: run_id,
|
|
34
|
+
executed_at: Time.current,
|
|
35
|
+
message_id: message_id
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
result
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
# Stop recursion if we reached the maximum number of attempts (3)
|
|
41
|
+
if attempt >= 3
|
|
42
|
+
Glancer::Utils::Logger.error("Workflow::Executor", "Final failure after #{attempt} attempts: #{e.message}")
|
|
43
|
+
return { error: true, message: e.message, last_code: sql }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Glancer::Utils::Logger.warn("Workflow::Executor",
|
|
47
|
+
"SQL Error (Attempt ##{attempt}): #{e.message}. Requesting correction...")
|
|
48
|
+
|
|
49
|
+
# Invoke the Builder to analyze the error and fix the SQL
|
|
50
|
+
fixed_sql = Glancer::Workflow::Builder.fix_sql(sql, e.message)
|
|
51
|
+
|
|
52
|
+
# Retry execution with the corrected SQL
|
|
53
|
+
execute(fixed_sql, original_question: original_question, attempt: attempt + 1, message_id: message_id)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.apply_statement_timeout(connection)
|
|
58
|
+
timeout_ms = Glancer.configuration.statement_timeout.to_i * 1000
|
|
59
|
+
adapter = Glancer.configuration.resolved_adapter.to_s
|
|
60
|
+
|
|
61
|
+
case adapter
|
|
62
|
+
when "postgres", "postgresql"
|
|
63
|
+
connection.execute("SET statement_timeout = #{timeout_ms}")
|
|
64
|
+
when "mysql", "mysql2"
|
|
65
|
+
connection.execute("SET max_execution_time = #{timeout_ms}")
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Glancer::Utils::Logger.warn("Workflow::Executor", "Could not set statement timeout: #{e.message}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|