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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
4
+ module Glancer
5
+ # Analyzes query result data and returns an array of Chart.js-compatible config hashes.
6
+ # Returns [] when no meaningful chart can be produced. No data is sent to any AI.
7
+ class ChartAnalyzer
8
+ MIN_ROWS = 2
9
+ MAX_PIE_CATEGORIES = 8
10
+ MAX_LINE_POINTS = 500
11
+ MAX_BAR_CATEGORIES = 60
12
+ MAX_SCATTER_POINTS = 1000
13
+
14
+ def self.analyze(data)
15
+ return [] unless suitable?(data)
16
+
17
+ keys = data.first.keys
18
+ date_col = detect_date_column(data, keys)
19
+ id_cols = detect_id_columns(keys)
20
+ all_numeric = detect_all_numeric(data, keys)
21
+
22
+ measure_cols = (all_numeric - id_cols).select do |k|
23
+ data.map { |r| r[k] }.compact.map { |v| to_f(v) }.uniq.size > 1
24
+ end
25
+
26
+ group_col = detect_group_column(data, keys, date_col: date_col, measure_cols: measure_cols)
27
+ label_col = detect_label_column(data, keys, exclude: [date_col, group_col].compact + all_numeric)
28
+
29
+ charts = []
30
+
31
+ charts << build_multi_series(data, date_col, group_col, measure_cols.first) \
32
+ if date_col && group_col && measure_cols.any?
33
+
34
+ if date_col && measure_cols.any? && !group_col && data.size <= MAX_LINE_POINTS
35
+ filled = fill_monthly_gaps(data, date_col, measure_cols)
36
+ charts << build_line(filled, date_col, measure_cols)
37
+ charts << build_bar(filled, date_col, measure_cols)
38
+ end
39
+
40
+ if !date_col && !group_col && label_col && measure_cols.any?
41
+ unique_labels = data.map { |r| r[label_col] }.uniq.size
42
+ if unique_labels <= MAX_BAR_CATEGORIES
43
+ charts << build_bar(data, label_col, measure_cols)
44
+ charts << build_doughnut(data, label_col, measure_cols.first) if measure_cols.size == 1 && unique_labels <= MAX_PIE_CATEGORIES
45
+ end
46
+ end
47
+
48
+ if measure_cols.size >= 2 && date_col.nil? && group_col.nil? && data.size <= MAX_SCATTER_POINTS
49
+ charts << build_scatter(data, measure_cols[0], measure_cols[1])
50
+ end
51
+
52
+ charts.compact
53
+ rescue StandardError
54
+ []
55
+ end
56
+
57
+ # ── Column classification ─────────────────────────────────────────────────
58
+
59
+ def self.detect_id_columns(keys)
60
+ keys.select { |k| k.to_s.match?(/\A\w+_id\z|\Aid\z/i) }
61
+ end
62
+
63
+ def self.detect_all_numeric(data, keys)
64
+ keys.select do |k|
65
+ vals = data.map { |r| r[k] }.compact
66
+ vals.any? && vals.all? { |v| numeric?(v) }
67
+ end
68
+ end
69
+
70
+ def self.detect_group_column(data, keys, date_col:, measure_cols:)
71
+ return nil unless date_col
72
+
73
+ id_like = keys.select { |k| k.to_s.match?(/\A\w+_id\z|\Aid\z/i) }
74
+ strings = (keys - [date_col]).select { |k| data.first[k].is_a?(String) || data.first[k].is_a?(Symbol) }
75
+ candidates = (id_like + strings) - measure_cols
76
+ candidates.find { |k| low_cardinality?(data, k) }
77
+ end
78
+
79
+ def self.low_cardinality?(data, col)
80
+ uniq = data.map { |r| r[col] }.uniq.size
81
+ uniq <= 30 && uniq < (data.size / 2.0).ceil
82
+ end
83
+
84
+ def self.detect_label_column(data, keys, exclude: [])
85
+ (keys - exclude).find { |k| data.first[k].is_a?(String) || data.first[k].is_a?(Symbol) }
86
+ end
87
+
88
+ # ── Date detection ────────────────────────────────────────────────────────
89
+
90
+ def self.detect_date_column(data, keys)
91
+ date_kw = /\A(date|time|month|year|day|week|quarter|period|created|updated|at)\z/i
92
+ loose = /date|time|month|year|day|week|period|created|updated/i
93
+
94
+ named = keys.find { |k| k.to_s.match?(date_kw) } ||
95
+ keys.find { |k| k.to_s.match?(loose) }
96
+ return named if named && date_values?(data, named)
97
+
98
+ keys.find { |k| date_values?(data, k) }
99
+ end
100
+
101
+ def self.date_values?(data, col)
102
+ samples = data.first(5).map { |r| r[col] }.compact
103
+ return false if samples.empty?
104
+
105
+ samples.all? { |v| v.is_a?(Date) || v.is_a?(Time) || date_string?(v) }
106
+ end
107
+
108
+ def self.date_string?(val)
109
+ return false unless val.is_a?(String)
110
+
111
+ val.match?(%r{\A\d{4}[-/]\d{2}([-/]\d{2})?|\A\d{2}[-/]\d{2}[-/]\d{4}|\A\d{4}-\d{2}\z})
112
+ rescue StandardError
113
+ false
114
+ end
115
+
116
+ def self.monthly_period?(str)
117
+ str.to_s.match?(/\A\d{4}-\d{2}\z/)
118
+ end
119
+
120
+ # ── Date gap filling ──────────────────────────────────────────────────────
121
+
122
+ def self.fill_monthly_gaps(data, date_col, measure_cols)
123
+ dates = data.map { |r| r[date_col].to_s }
124
+ return data unless dates.all? { |d| monthly_period?(d) }
125
+
126
+ by_date = data.each_with_object({}) { |row, h| h[row[date_col].to_s] = row }
127
+ all_months = expand_month_range(dates.min, dates.max)
128
+ base_row = data.first.transform_values { nil }
129
+ zero_fill = measure_cols.each_with_object({}) { |c, h| h[c] = 0 }
130
+
131
+ all_months.map do |month|
132
+ by_date[month] || base_row.merge({ date_col => month }).merge(zero_fill)
133
+ end
134
+ end
135
+
136
+ def self.expand_month_range(min_str, max_str)
137
+ current = Date.parse("#{min_str}-01")
138
+ last = Date.parse("#{max_str}-01")
139
+ months = []
140
+ while current <= last
141
+ months << current.strftime("%Y-%m")
142
+ current >>= 1
143
+ end
144
+ months
145
+ end
146
+
147
+ # ── Multi-series pivot builder ────────────────────────────────────────────
148
+
149
+ def self.build_multi_series(data, date_col, group_col, metric_col)
150
+ raw_dates = data.map { |r| r[date_col].to_s }.uniq
151
+ x_labels = if raw_dates.all? { |d| monthly_period?(d) }
152
+ expand_month_range(raw_dates.min, raw_dates.max)
153
+ else
154
+ raw_dates.sort
155
+ end
156
+ group_vals = data.map { |r| r[group_col] }.uniq.sort_by(&:to_s)
157
+ return nil if group_vals.size > MAX_BAR_CATEGORIES
158
+
159
+ lookup = data.each_with_object({}) { |r, h| h[[r[date_col].to_s, r[group_col]]] = to_f(r[metric_col]) }
160
+ datasets = group_vals.map { |gv| { label: gv.to_s, data: x_labels.map { |xl| lookup[[xl, gv]] || 0 } } }
161
+
162
+ { type: "line", labels: x_labels, datasets: datasets }
163
+ end
164
+
165
+ # ── Simple chart builders ─────────────────────────────────────────────────
166
+
167
+ def self.build_line(data, date_col, measure_cols)
168
+ sets = measure_cols.map { |col| { label: humanize(col), data: data.map { |r| to_f(r[col]) } } }
169
+ { type: "line", labels: data.map { |r| r[date_col].to_s }, datasets: sets }
170
+ end
171
+
172
+ def self.build_bar(data, label_col, measure_cols)
173
+ sets = measure_cols.map { |col| { label: humanize(col), data: data.map { |r| to_f(r[col]) } } }
174
+ { type: "bar", labels: data.map { |r| r[label_col].to_s }, datasets: sets }
175
+ end
176
+
177
+ def self.build_doughnut(data, label_col, metric_col)
178
+ {
179
+ type: "doughnut",
180
+ labels: data.map { |r| r[label_col].to_s },
181
+ datasets: [{ label: humanize(metric_col), data: data.map { |r| to_f(r[metric_col]) } }]
182
+ }
183
+ end
184
+
185
+ def self.build_scatter(data, x_col, y_col)
186
+ {
187
+ type: "scatter",
188
+ labels: nil,
189
+ xLabel: humanize(x_col),
190
+ yLabel: humanize(y_col),
191
+ datasets: [{
192
+ label: "#{humanize(x_col)} × #{humanize(y_col)}",
193
+ data: data.map { |r| { x: to_f(r[x_col]), y: to_f(r[y_col]) } }
194
+ }]
195
+ }
196
+ end
197
+
198
+ # ── Helpers ───────────────────────────────────────────────────────────────
199
+
200
+ def self.suitable?(data)
201
+ data.is_a?(Array) &&
202
+ data.size >= MIN_ROWS &&
203
+ data.first.is_a?(Hash) &&
204
+ data.first.keys.size >= 2
205
+ end
206
+
207
+ def self.numeric?(val)
208
+ val.is_a?(Integer) || val.is_a?(Float) ||
209
+ (val.is_a?(String) && val.strip.match?(/\A-?\d+(\.\d+)?\z/))
210
+ end
211
+
212
+ def self.to_f(val)
213
+ return val.to_f if val.is_a?(Numeric)
214
+
215
+ val.to_s.gsub(",", ".").to_f
216
+ end
217
+
218
+ def self.humanize(col)
219
+ col.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
220
+ end
221
+
222
+ private_class_method :suitable?, :detect_id_columns, :detect_all_numeric,
223
+ :detect_group_column, :low_cardinality?, :detect_label_column,
224
+ :detect_date_column, :date_values?, :date_string?, :monthly_period?,
225
+ :fill_monthly_gaps, :expand_month_range, :build_multi_series,
226
+ :build_line, :build_bar, :build_doughnut, :build_scatter,
227
+ :numeric?, :to_f, :humanize
228
+ end
229
+ end
230
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class Configuration
5
+ ADAPTERS_SUPPORTED = %i[postgres mysql mysql2 sqlite].freeze
6
+ LLM_PROVIDERS = %i[gemini openai openrouter].freeze
7
+ LOG_VERBOSITY_LEVELS = %i[silent none info debug].freeze
8
+ QUERY_MODES = %i[sql activerecord].freeze
9
+
10
+ # Default embedding models per provider.
11
+ # OpenRouter does not expose a native embedding API; the recommended approach
12
+ # is to set embedding_provider to :gemini or :openai and only use openrouter
13
+ # for chat/sql roles.
14
+ EMBEDDING_DEFAULTS = {
15
+ gemini: "text-embedding-004",
16
+ openai: "text-embedding-3-large",
17
+ openrouter: "openai/text-embedding-3-small"
18
+ }.freeze
19
+
20
+ def initialize
21
+ self.adapter = Glancer::Configuration.infer_adapter || :mysql2
22
+ self.read_only_db = nil
23
+ self.llm_provider = :gemini
24
+ self.llm_model = "gemini-2.0-flash"
25
+ self.schema_permission = false
26
+ self.models_permission = false
27
+ self.workflow_cache_ttl = 5.minutes
28
+ self.context_file_path = "config/glancer/llm_context.glancer.md"
29
+ self.api_key = nil
30
+ self.gemini_api_key = nil
31
+ self.openai_api_key = nil
32
+ self.openrouter_api_key = nil
33
+ self.log_output_path = nil
34
+ self.log_verbosity = :info
35
+ self.k = 5
36
+ self.min_score = 0.6
37
+ self.schema_documents_weight = 1.3
38
+ self.context_documents_weight = 1.2
39
+ self.models_documents_weight = 1.1
40
+ self.chunk_size = 1000
41
+ self.chunk_overlap = 150
42
+ self.history_limit = 6
43
+ self.statement_timeout = 30.seconds
44
+ self.embedding_provider = nil # nil → uses llm_provider
45
+ self.embedding_model = nil # nil → uses provider default
46
+ self.code_provider = nil # nil → uses llm_provider (for SQL generation)
47
+ self.code_model = nil # nil → uses llm_model (for SQL generation)
48
+ self.chat_provider = nil # nil → uses llm_provider (for humanized responses)
49
+ self.chat_model = nil # nil → uses llm_model (for humanized responses)
50
+ self.blazer_path = nil # nil → auto-detected if Blazer::Engine is mounted
51
+ self.query_mode = :sql # :sql (default) or :activerecord
52
+ self.query_enrichment_enabled = false # enrich question with table names before retrieval
53
+ self.enrichment_provider = nil # nil → falls back to llm_provider
54
+ self.enrichment_model = nil # nil → falls back to llm_model
55
+ end
56
+
57
+ # === READERS ===
58
+ attr_reader :adapter, :read_only_db, :llm_provider, :llm_model,
59
+ :schema_permission, :models_permission, :workflow_cache_ttl,
60
+ :context_file_path, :api_key, :gemini_api_key, :openai_api_key, :openrouter_api_key,
61
+ :log_output_path, :log_verbosity,
62
+ :k, :min_score,
63
+ :schema_documents_weight, :context_documents_weight, :models_documents_weight,
64
+ :chunk_size, :chunk_overlap, :history_limit, :statement_timeout,
65
+ :embedding_provider, :embedding_model,
66
+ :code_provider, :code_model,
67
+ :chat_provider, :chat_model,
68
+ :blazer_path, :query_mode,
69
+ :query_enrichment_enabled, :enrichment_provider, :enrichment_model
70
+
71
+ # === WRITERS ===
72
+ def adapter=(value)
73
+ unless ADAPTERS_SUPPORTED.join(", ").include?(value.to_s)
74
+ raise ArgumentError, "adapter must be #{
75
+ ADAPTERS_SUPPORTED.join(", ")
76
+ }" || value.nil?
77
+ end
78
+
79
+ @adapter = value
80
+ end
81
+
82
+ def read_only_db=(value)
83
+ unless value.nil? || value.is_a?(String) || value == :read_only
84
+ raise ArgumentError, "read_only_db must be nil, a connection URL string, or :read_only"
85
+ end
86
+
87
+ @read_only_db = value
88
+ end
89
+
90
+ def llm_provider=(value)
91
+ unless LLM_PROVIDERS.include?(value)
92
+ raise ArgumentError, "llm_provider must be #{
93
+ LLM_PROVIDERS.join(", ")
94
+ }"
95
+ end
96
+
97
+ @llm_provider = value
98
+ end
99
+
100
+ def llm_model=(value)
101
+ raise ArgumentError, "llm_model must be a String" unless value.is_a?(String)
102
+
103
+ @llm_model = value
104
+ end
105
+
106
+ def schema_permission=(value)
107
+ raise ArgumentError, "schema_permission must be true or false" unless [true, false].include?(value)
108
+
109
+ @schema_permission = value
110
+ end
111
+
112
+ def models_permission=(value)
113
+ raise ArgumentError, "models_permission must be true or false" unless [true, false].include?(value)
114
+
115
+ @models_permission = value
116
+ end
117
+
118
+ def workflow_cache_ttl=(value)
119
+ raise ArgumentError, "workflow_cache_ttl must respond to to_i" unless value.respond_to?(:to_i)
120
+
121
+ @workflow_cache_ttl = value
122
+ end
123
+
124
+ def context_file_path=(value)
125
+ raise ArgumentError, "context_file_path must be a String" unless value.is_a?(String)
126
+
127
+ @context_file_path = value
128
+ end
129
+
130
+ def api_key=(value)
131
+ raise ArgumentError, "api_key must be nil or a String" unless value.nil? || value.is_a?(String)
132
+
133
+ @api_key = value
134
+ end
135
+
136
+ def gemini_api_key=(value)
137
+ raise ArgumentError, "gemini_api_key must be nil or a String" unless value.nil? || value.is_a?(String)
138
+
139
+ @gemini_api_key = value
140
+ end
141
+
142
+ def openai_api_key=(value)
143
+ raise ArgumentError, "openai_api_key must be nil or a String" unless value.nil? || value.is_a?(String)
144
+
145
+ @openai_api_key = value
146
+ end
147
+
148
+ def openrouter_api_key=(value)
149
+ raise ArgumentError, "openrouter_api_key must be nil or a String" unless value.nil? || value.is_a?(String)
150
+
151
+ @openrouter_api_key = value
152
+ end
153
+
154
+ def log_output_path=(value)
155
+ raise ArgumentError, "log_output_path must be nil or a String" unless value.nil? || value.is_a?(String)
156
+
157
+ @log_output_path = value
158
+ end
159
+
160
+ def log_verbosity=(value)
161
+ raise ArgumentError, "log_verbosity must be :silent, :none, :info, or :debug" unless LOG_VERBOSITY_LEVELS.include?(value)
162
+
163
+ @log_verbosity = value
164
+ end
165
+
166
+ def k=(value)
167
+ raise ArgumentError, "k must be an integer ≥ 1" unless value.is_a?(Integer) && value >= 1
168
+
169
+ @k = value
170
+ end
171
+
172
+ def min_score=(value)
173
+ unless value.is_a?(Numeric) && value.between?(
174
+ 0.0, 1.0
175
+ )
176
+ raise ArgumentError,
177
+ "min_score must be a number between 0.0 and 1.0"
178
+ end
179
+
180
+ @min_score = value
181
+ end
182
+
183
+ def schema_documents_weight=(value)
184
+ unless value.is_a?(Numeric) && value >= 1
185
+ raise ArgumentError,
186
+ "schema_documents_weight must be a positive number greater than or equal to 1"
187
+ end
188
+
189
+ @schema_documents_weight = value
190
+ end
191
+
192
+ def context_documents_weight=(value)
193
+ unless value.is_a?(Numeric) && value >= 1
194
+ raise ArgumentError,
195
+ "context_documents_weight must be a positive number greater than or equal to 1"
196
+ end
197
+
198
+ @context_documents_weight = value
199
+ end
200
+
201
+ def models_documents_weight=(value)
202
+ unless value.is_a?(Numeric) && value >= 1
203
+ raise ArgumentError,
204
+ "models_documents_weight must be a positive number greater than or equal to 1"
205
+ end
206
+
207
+ @models_documents_weight = value
208
+ end
209
+
210
+ def chunk_size=(value)
211
+ raise ArgumentError, "chunk_size must be an integer ≥ 100" unless value.is_a?(Integer) && value >= 100
212
+
213
+ @chunk_size = value
214
+ end
215
+
216
+ def chunk_overlap=(value)
217
+ raise ArgumentError, "chunk_overlap must be an integer ≥ 0" unless value.is_a?(Integer) && value >= 0
218
+
219
+ @chunk_overlap = value
220
+ end
221
+
222
+ def history_limit=(value)
223
+ raise ArgumentError, "history_limit must be an integer ≥ 1" unless value.is_a?(Integer) && value >= 1
224
+
225
+ @history_limit = value
226
+ end
227
+
228
+ def statement_timeout=(value)
229
+ raise ArgumentError, "statement_timeout must respond to to_i" unless value.respond_to?(:to_i)
230
+
231
+ @statement_timeout = value
232
+ end
233
+
234
+ def embedding_provider=(value)
235
+ unless value.nil? || LLM_PROVIDERS.include?(value)
236
+ raise ArgumentError, "embedding_provider must be nil or one of: #{LLM_PROVIDERS.join(", ")}"
237
+ end
238
+
239
+ @embedding_provider = value
240
+ end
241
+
242
+ def embedding_model=(value)
243
+ raise ArgumentError, "embedding_model must be nil or a String" unless value.nil? || value.is_a?(String)
244
+
245
+ @embedding_model = value
246
+ end
247
+
248
+ # Returns the provider to use for embedding calls (falls back to llm_provider).
249
+ def resolved_embedding_provider
250
+ embedding_provider || llm_provider
251
+ end
252
+
253
+ # Returns the model to use for embedding calls.
254
+ # Falls back to EMBEDDING_DEFAULTS for the resolved provider.
255
+ def resolved_embedding_model
256
+ embedding_model || EMBEDDING_DEFAULTS[resolved_embedding_provider] || "text-embedding-004"
257
+ end
258
+
259
+ def code_provider=(value)
260
+ unless value.nil? || LLM_PROVIDERS.include?(value)
261
+ raise ArgumentError, "code_provider must be nil or one of: #{LLM_PROVIDERS.join(", ")}"
262
+ end
263
+
264
+ @code_provider = value
265
+ end
266
+
267
+ def code_model=(value)
268
+ raise ArgumentError, "code_model must be nil or a String" unless value.nil? || value.is_a?(String)
269
+
270
+ @code_model = value
271
+ end
272
+
273
+ def chat_provider=(value)
274
+ unless value.nil? || LLM_PROVIDERS.include?(value)
275
+ raise ArgumentError, "chat_provider must be nil or one of: #{LLM_PROVIDERS.join(", ")}"
276
+ end
277
+
278
+ @chat_provider = value
279
+ end
280
+
281
+ def chat_model=(value)
282
+ raise ArgumentError, "chat_model must be nil or a String" unless value.nil? || value.is_a?(String)
283
+
284
+ @chat_model = value
285
+ end
286
+
287
+ def resolved_code_provider
288
+ code_provider || llm_provider
289
+ end
290
+
291
+ def resolved_code_model
292
+ code_model || llm_model
293
+ end
294
+
295
+ def resolved_chat_provider
296
+ chat_provider || llm_provider
297
+ end
298
+
299
+ def resolved_chat_model
300
+ chat_model || llm_model
301
+ end
302
+
303
+ def query_enrichment_enabled=(value)
304
+ raise ArgumentError, "query_enrichment_enabled must be true or false" unless [true, false].include?(value)
305
+
306
+ @query_enrichment_enabled = value
307
+ end
308
+
309
+ def enrichment_provider=(value)
310
+ unless value.nil? || LLM_PROVIDERS.include?(value)
311
+ raise ArgumentError, "enrichment_provider must be nil or one of: #{LLM_PROVIDERS.join(", ")}"
312
+ end
313
+
314
+ @enrichment_provider = value
315
+ end
316
+
317
+ def enrichment_model=(value)
318
+ raise ArgumentError, "enrichment_model must be nil or a String" unless value.nil? || value.is_a?(String)
319
+
320
+ @enrichment_model = value
321
+ end
322
+
323
+ def resolved_enrichment_provider
324
+ enrichment_provider || llm_provider
325
+ end
326
+
327
+ def resolved_enrichment_model
328
+ enrichment_model || llm_model
329
+ end
330
+
331
+ def blazer_path=(value)
332
+ raise ArgumentError, "blazer_path must be nil or a String" unless value.nil? || value.is_a?(String)
333
+
334
+ @blazer_path = value
335
+ end
336
+
337
+ def query_mode=(value)
338
+ raise ArgumentError, "query_mode must be one of: #{QUERY_MODES.join(", ")}" unless QUERY_MODES.include?(value)
339
+
340
+ @query_mode = value
341
+ end
342
+
343
+ # Returns the Blazer base path if Blazer is available, nil otherwise.
344
+ def resolved_blazer_path
345
+ return @blazer_path unless @blazer_path.nil?
346
+
347
+ defined?(Blazer::Engine) ? "/blazer" : nil
348
+ end
349
+
350
+ # Returns the adapter in use, auto-detecting from ActiveRecord when nil.
351
+ # Normalizes "postgresql" → :postgres so callers don't need to handle both.
352
+ def resolved_adapter
353
+ raw = adapter || self.class.infer_adapter || :mysql2
354
+ raw.to_s == "postgresql" ? :postgres : raw
355
+ end
356
+
357
+ # === Auxiliary methods ===
358
+
359
+ def self.infer_adapter
360
+ raw = ActiveRecord::Base.connection.adapter_name.downcase.to_sym
361
+ raw.to_s == "postgresql" ? :postgres : raw
362
+ rescue StandardError
363
+ nil
364
+ end
365
+
366
+ def self.valid_table_name?(table_name)
367
+ ActiveRecord::Base.connection.tables.include?(table_name.to_s)
368
+ rescue StandardError
369
+ false
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Glancer
6
+
7
+ config.i18n.load_path += Dir[root.join("config/locales/*.yml").to_s]
8
+
9
+ initializer "glancer.append_migrations" do |app|
10
+ unless app.root.to_s.match?(root.to_s)
11
+ config.paths["db/migrate"].expanded.each do |expanded_path|
12
+ app.config.paths["db/migrate"] << expanded_path
13
+ end
14
+ end
15
+ end
16
+
17
+ initializer "glancer.assets" do |app|
18
+ app.config.assets.paths << root.join("app/assets/javascripts")
19
+ app.config.assets.precompile += %w[
20
+ glancer/application.js
21
+ ]
22
+
23
+ app.config.assets.paths << root.join("app/assets/stylesheets")
24
+ app.config.assets.precompile += %w[
25
+ glancer/application.css
26
+ glancer/code-blocks.css
27
+ glancer/table.css
28
+ glancer/list.css
29
+ glancer/scrollbar.css
30
+ ]
31
+ app.config.assets.paths << root.join("app/assets/config")
32
+ app.config.assets.paths << root.join("app/assets/images")
33
+ end
34
+
35
+ initializer "glancer.load_tasks" do
36
+ Dir[File.join(__dir__, "../../tasks/**/*.rake")].each { |f| load f }
37
+ end
38
+
39
+ initializer "glancer.configure_ruby_llm" do
40
+ next unless Glancer.configuration
41
+
42
+ Glancer::Utils::Logger.info("Engine", "Configuring RubyLLM with Glancer settings...")
43
+
44
+ RubyLLM.configure do |config|
45
+ glancer_cfg = Glancer.configuration
46
+ provider = glancer_cfg.llm_provider.to_sym
47
+ embed_provider = glancer_cfg.resolved_embedding_provider.to_sym
48
+
49
+ Glancer::Utils::Logger.debug("Engine", "LLM provider: #{provider}, embedding provider: #{embed_provider}")
50
+
51
+ Glancer::Engine.configure_provider_key(config, glancer_cfg, provider)
52
+ Glancer::Engine.configure_provider_key(config, glancer_cfg, embed_provider) if embed_provider != provider
53
+
54
+ config.default_embedding_model = glancer_cfg.resolved_embedding_model
55
+
56
+ Glancer::Utils::Logger.info("Engine",
57
+ "RubyLLM configured — chat: #{provider}/#{glancer_cfg.llm_model}, " \
58
+ "embeddings: #{embed_provider}/#{config.default_embedding_model}")
59
+ end
60
+
61
+ Glancer::Utils::Logger.info("Engine", "RubyLLM configuration completed.")
62
+ rescue StandardError => e
63
+ Glancer::Utils::Logger.error("Engine", "Failed to configure RubyLLM: #{e.class} - #{e.message}")
64
+ Glancer::Utils::Logger.debug("Engine", "Backtrace:\n#{e.backtrace.join("\n")}")
65
+ raise Glancer::Error, "RubyLLM configuration failed: #{e.message}"
66
+ end
67
+
68
+ def self.configure_provider_key(config, glancer_cfg, provider)
69
+ case provider
70
+ when :gemini
71
+ key = glancer_cfg.gemini_api_key || glancer_cfg.api_key
72
+ raise Glancer::Error, "Gemini API key is required but not configured." if key.nil? || key.empty?
73
+
74
+ config.gemini_api_key = key
75
+ when :openai
76
+ key = glancer_cfg.openai_api_key || glancer_cfg.api_key
77
+ raise Glancer::Error, "OpenAI API key is required but not configured." if key.nil? || key.empty?
78
+
79
+ config.openai_api_key = key
80
+ when :openrouter
81
+ key = glancer_cfg.openrouter_api_key || glancer_cfg.api_key
82
+ raise Glancer::Error, "OpenRouter API key is required but not configured." if key.nil? || key.empty?
83
+
84
+ config.openrouter_api_key = key
85
+ else
86
+ raise Glancer::Error, "Unsupported LLM provider: #{provider.inspect}"
87
+ end
88
+ end
89
+ end
90
+ end