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