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.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +96 -0
  3. data/.rubocop.yml +54 -0
  4. data/CHANGELOG.md +88 -0
  5. data/CLAUDE.md +115 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/README.md +354 -0
  8. data/app/assets/config/glancer_manifest.js +1 -0
  9. data/app/assets/javascripts/glancer/application.js +15 -0
  10. data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
  11. data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
  12. data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
  13. data/app/assets/stylesheets/glancer/application.css +350 -0
  14. data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
  15. data/app/assets/stylesheets/glancer/list.css +31 -0
  16. data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
  17. data/app/assets/stylesheets/glancer/table.css +97 -0
  18. data/app/controllers/glancer/application_controller.rb +33 -0
  19. data/app/controllers/glancer/chats_controller.rb +49 -0
  20. data/app/controllers/glancer/messages_controller.rb +144 -0
  21. data/app/controllers/glancer/schema_controller.rb +29 -0
  22. data/app/controllers/glancer/settings_controller.rb +23 -0
  23. data/app/helpers/glancer/application_helper.rb +17 -0
  24. data/app/jobs/glancer/application_job.rb +6 -0
  25. data/app/jobs/glancer/process_message_job.rb +38 -0
  26. data/app/models/glancer/audit.rb +12 -0
  27. data/app/models/glancer/chat.rb +8 -0
  28. data/app/models/glancer/code_version.rb +12 -0
  29. data/app/models/glancer/embedding.rb +6 -0
  30. data/app/models/glancer/message.rb +25 -0
  31. data/app/models/glancer/setting.rb +23 -0
  32. data/app/models/glancer/sql_version.rb +6 -0
  33. data/app/views/glancer/_data/_importmap.json.erb +7 -0
  34. data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
  35. data/app/views/glancer/chats/_show.html.erb +52 -0
  36. data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
  37. data/app/views/glancer/chats/index.html.erb +10 -0
  38. data/app/views/glancer/chats/show.html.erb +1 -0
  39. data/app/views/glancer/messages/_data_table.html.erb +268 -0
  40. data/app/views/glancer/messages/_execution_error.html.erb +26 -0
  41. data/app/views/glancer/messages/_form.html.erb +93 -0
  42. data/app/views/glancer/messages/_message.html.erb +206 -0
  43. data/app/views/glancer/messages/_message_info.html.erb +176 -0
  44. data/app/views/glancer/messages/_temp_form.html.erb +100 -0
  45. data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
  46. data/app/views/glancer/schema/show.html.erb +123 -0
  47. data/app/views/glancer/settings/show.html.erb +306 -0
  48. data/app/views/glancer/shared/_icons.html.erb +126 -0
  49. data/app/views/layouts/glancer/application.html.erb +234 -0
  50. data/config/locales/glancer.en.yml +90 -0
  51. data/config/locales/glancer.es.yml +90 -0
  52. data/config/locales/glancer.pt-BR.yml +90 -0
  53. data/config/routes.rb +20 -0
  54. data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
  55. data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
  56. data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
  57. data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
  58. data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
  59. data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
  60. data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
  61. data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
  62. data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
  63. data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
  64. data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
  65. data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
  66. data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
  67. data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
  68. data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
  69. data/lib/generators/glancer/install/install_generator.rb +74 -0
  70. data/lib/generators/glancer/install/templates/glancer.rb +227 -0
  71. data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
  72. data/lib/glancer/async_runner.rb +50 -0
  73. data/lib/glancer/chart_analyzer.rb +230 -0
  74. data/lib/glancer/configuration.rb +372 -0
  75. data/lib/glancer/engine.rb +90 -0
  76. data/lib/glancer/indexer/context_indexer.rb +58 -0
  77. data/lib/glancer/indexer/model_indexer.rb +64 -0
  78. data/lib/glancer/indexer/schema_indexer.rb +171 -0
  79. data/lib/glancer/indexer.rb +50 -0
  80. data/lib/glancer/retriever.rb +114 -0
  81. data/lib/glancer/utils/logger.rb +83 -0
  82. data/lib/glancer/utils/markdown_helper.rb +56 -0
  83. data/lib/glancer/utils/result_formatter.rb +25 -0
  84. data/lib/glancer/utils/table_stats.rb +18 -0
  85. data/lib/glancer/utils/transaction.rb +59 -0
  86. data/lib/glancer/version.rb +5 -0
  87. data/lib/glancer/workflow/ar_executor.rb +104 -0
  88. data/lib/glancer/workflow/ar_extractor.rb +25 -0
  89. data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
  90. data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
  91. data/lib/glancer/workflow/builder.rb +129 -0
  92. data/lib/glancer/workflow/cache.rb +55 -0
  93. data/lib/glancer/workflow/executor.rb +72 -0
  94. data/lib/glancer/workflow/llm.rb +123 -0
  95. data/lib/glancer/workflow/prompt_builder.rb +143 -0
  96. data/lib/glancer/workflow/query_enricher.rb +117 -0
  97. data/lib/glancer/workflow/sql_extractor.rb +42 -0
  98. data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
  99. data/lib/glancer/workflow/sql_validator.rb +67 -0
  100. data/lib/glancer/workflow.rb +158 -0
  101. data/lib/glancer.rb +50 -0
  102. data/lib/tasks/glancer/tailwind.rake +8 -0
  103. data/lib/tasks/glancer.rake +99 -0
  104. data/spec/glancer_spec.rb +62 -0
  105. data/spec/lib/glancer/async_runner_spec.rb +133 -0
  106. data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
  107. data/spec/lib/glancer/configuration_spec.rb +858 -0
  108. data/spec/lib/glancer/engine_spec.rb +209 -0
  109. data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
  110. data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
  111. data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
  112. data/spec/lib/glancer/indexer_spec.rb +95 -0
  113. data/spec/lib/glancer/retriever_spec.rb +179 -0
  114. data/spec/lib/glancer/utils/logger_spec.rb +85 -0
  115. data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
  116. data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
  117. data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
  118. data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
  119. data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
  120. data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
  121. data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
  122. data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
  123. data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
  124. data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
  125. data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
  126. data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
  127. data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
  128. data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
  129. data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
  130. data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
  131. data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
  132. data/spec/lib/glancer/workflow_spec.rb +308 -0
  133. data/spec/models/glancer/audit_spec.rb +82 -0
  134. data/spec/models/glancer/chat_spec.rb +60 -0
  135. data/spec/models/glancer/code_version_spec.rb +71 -0
  136. data/spec/models/glancer/embedding_spec.rb +73 -0
  137. data/spec/models/glancer/message_spec.rb +144 -0
  138. data/spec/models/glancer/setting_spec.rb +88 -0
  139. data/spec/models/glancer/sql_version_spec.rb +4 -0
  140. data/spec/spec_helper.rb +128 -0
  141. data/spec/support/schema.rb +55 -0
  142. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ VERSION = "1.0.0"
5
+ 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