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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::ChartAnalyzer do
6
+ # ── Helpers ────────────────────────────────────────────────────────────────
7
+
8
+ def make_rows(count, **cols)
9
+ Array.new(count) { |i| cols.transform_values { |v| v.respond_to?(:call) ? v.call(i) : v } }
10
+ end
11
+
12
+ # ── .analyze ──────────────────────────────────────────────────────────────
13
+
14
+ describe ".analyze" do
15
+ context "when data is not suitable" do
16
+ it "returns [] for an empty array" do
17
+ expect(described_class.analyze([])).to eq([])
18
+ end
19
+
20
+ it "returns [] for a single row" do
21
+ expect(described_class.analyze([{ a: 1, b: 2 }])).to eq([])
22
+ end
23
+
24
+ it "returns [] when rows have only one column" do
25
+ expect(described_class.analyze([{ a: 1 }, { a: 2 }])).to eq([])
26
+ end
27
+
28
+ it "returns [] when rows are not hashes" do
29
+ expect(described_class.analyze([[1, 2], [3, 4]])).to eq([])
30
+ end
31
+
32
+ it "returns [] when data is not an Array" do
33
+ expect(described_class.analyze("not an array")).to eq([])
34
+ end
35
+ end
36
+
37
+ context "line chart (date + numeric measure)" do
38
+ let(:data) do
39
+ [
40
+ { "month" => "2024-01", "revenue" => 1000 },
41
+ { "month" => "2024-02", "revenue" => 2000 },
42
+ { "month" => "2024-03", "revenue" => 1500 }
43
+ ]
44
+ end
45
+
46
+ it "includes a line chart" do
47
+ charts = described_class.analyze(data)
48
+ types = charts.map { |c| c[:type] }
49
+ expect(types).to include("line")
50
+ end
51
+
52
+ it "populates labels and datasets" do
53
+ line = described_class.analyze(data).find { |c| c[:type] == "line" && !c[:datasets].first[:label].to_s.empty? }
54
+ expect(line).not_to be_nil
55
+ expect(line[:datasets].first[:data]).not_to be_empty
56
+ end
57
+ end
58
+
59
+ context "doughnut chart (label + single numeric, low cardinality)" do
60
+ let(:data) do
61
+ [
62
+ { "category" => "A", "count" => 10 },
63
+ { "category" => "B", "count" => 20 },
64
+ { "category" => "C", "count" => 30 }
65
+ ]
66
+ end
67
+
68
+ it "includes a doughnut chart" do
69
+ charts = described_class.analyze(data)
70
+ types = charts.map { |c| c[:type] }
71
+ expect(types).to include("doughnut")
72
+ end
73
+
74
+ it "sets the correct labels" do
75
+ doughnut = described_class.analyze(data).find { |c| c[:type] == "doughnut" }
76
+ expect(doughnut[:labels]).to contain_exactly("A", "B", "C")
77
+ end
78
+ end
79
+
80
+ context "bar chart (label + multiple numerics)" do
81
+ let(:data) do
82
+ [
83
+ { "region" => "North", "sales" => 100, "returns" => 10 },
84
+ { "region" => "South", "sales" => 200, "returns" => 20 },
85
+ { "region" => "East", "sales" => 150, "returns" => 15 }
86
+ ]
87
+ end
88
+
89
+ it "includes a bar chart" do
90
+ charts = described_class.analyze(data)
91
+ types = charts.map { |c| c[:type] }
92
+ expect(types).to include("bar")
93
+ end
94
+
95
+ it "has multiple datasets" do
96
+ bar = described_class.analyze(data).find { |c| c[:type] == "bar" }
97
+ expect(bar[:datasets].size).to be >= 2
98
+ end
99
+ end
100
+
101
+ context "scatter chart (two numerics, no date/group)" do
102
+ let(:data) do
103
+ Array.new(5) { |i| { "x_val" => i * 1.0, "y_val" => i * 2.0 } }
104
+ end
105
+
106
+ it "includes a scatter chart" do
107
+ charts = described_class.analyze(data)
108
+ types = charts.map { |c| c[:type] }
109
+ expect(types).to include("scatter")
110
+ end
111
+
112
+ it "populates datasets with x/y pairs" do
113
+ scatter = described_class.analyze(data).find { |c| c[:type] == "scatter" }
114
+ expect(scatter[:datasets].first[:data].first).to have_key(:x)
115
+ expect(scatter[:datasets].first[:data].first).to have_key(:y)
116
+ end
117
+ end
118
+
119
+ context "multi-series (date + group + metric)" do
120
+ # Need enough rows so low_cardinality? passes:
121
+ # uniq(regions=2) < (rows / 2.0).ceil → 2 < ceil(6/2) = 3 → true ✓
122
+ # Values must vary (uniq > 1) so measure_cols filter passes ✓
123
+ let(:data) do
124
+ months = %w[2024-01 2024-02 2024-03]
125
+ regions = %w[North South]
126
+ months.each_with_index.flat_map do |m, i|
127
+ regions.map { |r| { "month" => m, "region" => r, "sales" => (i + 1) * 100 } }
128
+ end
129
+ end
130
+
131
+ it "includes a multi-series line chart" do
132
+ charts = described_class.analyze(data)
133
+ types = charts.map { |c| c[:type] }
134
+ expect(types).to include("line")
135
+ end
136
+
137
+ it "builds a dataset per region" do
138
+ chart = described_class.analyze(data).find { |c| c[:type] == "line" && c[:datasets].size >= 2 }
139
+ expect(chart).not_to be_nil
140
+ expect(chart[:datasets].size).to eq(2)
141
+ end
142
+
143
+ it "returns nil for multi-series when group cardinality exceeds MAX_BAR_CATEGORIES" do
144
+ # 61 distinct groups > MAX_BAR_CATEGORIES(60) → build_multi_series returns nil → compacted out
145
+ many_regions = (1..61).map { |i| "region_#{i}" }
146
+ big_data = many_regions.flat_map do |r|
147
+ %w[2024-01 2024-02 2024-03].map { |m| { "month" => m, "region" => r, "sales" => 10 } }
148
+ end
149
+ # low_cardinality requires uniq < rows/2; 61 < 183/2=92 → true (group_col detected)
150
+ charts = described_class.analyze(big_data)
151
+ multi = charts.select { |c| c[:type] == "line" && c[:datasets]&.size.to_i > 10 }
152
+ # build_multi_series returns nil → it gets compacted out
153
+ expect(multi).to be_empty
154
+ end
155
+ end
156
+
157
+ context "multi-series with non-monthly date strings" do
158
+ # Dates like "2024-01-01" are not monthly_period? → raw_dates.sort path (L156)
159
+ # Values must vary so measure_cols passes the uniqueness filter
160
+ let(:data) do
161
+ dates = %w[2024-01-01 2024-02-01 2024-03-01]
162
+ regions = %w[East West]
163
+ dates.each_with_index.flat_map do |d, i|
164
+ regions.map { |r| { "date" => d, "region" => r, "sales" => (i + 1) * 50 } }
165
+ end
166
+ end
167
+
168
+ it "still produces a line chart using sorted raw dates" do
169
+ charts = described_class.analyze(data)
170
+ types = charts.map { |c| c[:type] }
171
+ expect(types).to include("line")
172
+ end
173
+ end
174
+
175
+ context "on internal error" do
176
+ it "returns [] instead of raising" do
177
+ bad_data = [{ a: nil, b: nil }, { a: nil, b: nil }]
178
+ expect { described_class.analyze(bad_data) }.not_to raise_error
179
+ end
180
+
181
+ it "returns [] when an unexpected StandardError is raised internally" do
182
+ allow(described_class).to receive(:detect_date_column).and_raise(StandardError, "internal failure")
183
+ data = [{ "a" => "x", "b" => 1 }, { "a" => "y", "b" => 2 }]
184
+ expect(described_class.analyze(data)).to eq([])
185
+ end
186
+ end
187
+ end
188
+
189
+ # ── date_string? rescue path ──────────────────────────────────────────────
190
+
191
+ describe ".date_string? rescue path" do
192
+ it "returns false when match? raises" do
193
+ exploding = Class.new(String) { def match?(*_) = raise(StandardError, "oops") }.new("2024-01")
194
+ expect(described_class.send(:date_string?, exploding)).to be(false)
195
+ end
196
+ end
197
+
198
+ # ── date_string? (via analyze with date-string data) ─────────────────────
199
+
200
+ describe "date_string? behavior" do
201
+ it "treats YYYY-MM-DD strings as dates" do
202
+ data = Array.new(3) { |i| { "date" => "2024-0#{i + 1}-01", "val" => i } }
203
+ charts = described_class.analyze(data)
204
+ expect(charts).not_to be_empty
205
+ end
206
+
207
+ it "treats YYYY-MM strings as monthly periods" do
208
+ data = [
209
+ { "period" => "2024-01", "sales" => 100 },
210
+ { "period" => "2024-02", "sales" => 200 },
211
+ { "period" => "2024-03", "sales" => 150 }
212
+ ]
213
+ charts = described_class.analyze(data)
214
+ line = charts.find { |c| c[:type] == "line" }
215
+ expect(line).not_to be_nil
216
+ end
217
+ end
218
+
219
+ # ── fill_monthly_gaps ─────────────────────────────────────────────────────
220
+
221
+ describe "monthly gap filling" do
222
+ it "fills missing months between start and end" do
223
+ data = [
224
+ { "month" => "2024-01", "sales" => 100 },
225
+ { "month" => "2024-03", "sales" => 300 }
226
+ ]
227
+ # The line chart should include 2024-02 with 0 sales
228
+ line = described_class.analyze(data).find { |c| c[:type] == "line" }
229
+ expect(line).not_to be_nil
230
+ expect(line[:labels]).to include("2024-02")
231
+ end
232
+ end
233
+
234
+ # ── numeric? helper (exercised through analyze) ───────────────────────────
235
+
236
+ describe "numeric? behavior" do
237
+ it "treats integer rows as numeric" do
238
+ data = [{ "a" => "Label", "b" => 10 }, { "a" => "Other", "b" => 20 }]
239
+ charts = described_class.analyze(data)
240
+ expect(charts).not_to be_empty
241
+ end
242
+
243
+ it "treats float strings as numeric" do
244
+ data = [{ "a" => "X", "b" => "1.5" }, { "a" => "Y", "b" => "2.5" }]
245
+ charts = described_class.analyze(data)
246
+ expect(charts).not_to be_empty
247
+ end
248
+ end
249
+
250
+ # ── humanize helper ───────────────────────────────────────────────────────
251
+
252
+ describe "humanize behavior" do
253
+ it "converts snake_case column names to Title Case in datasets" do
254
+ data = [
255
+ { "category" => "A", "total_orders" => 5 },
256
+ { "category" => "B", "total_orders" => 10 }
257
+ ]
258
+ charts = described_class.analyze(data)
259
+ chart = charts.find { |c| c[:datasets] }
260
+ label = chart[:datasets].first[:label]
261
+ expect(label).to match(/Total Orders/i)
262
+ end
263
+ end
264
+
265
+ # ── id column exclusion ───────────────────────────────────────────────────
266
+
267
+ describe "id column exclusion from measures" do
268
+ it "does not use id columns as metric values" do
269
+ data = [
270
+ { "user_id" => 1, "category" => "A", "revenue" => 100 },
271
+ { "user_id" => 2, "category" => "B", "revenue" => 200 }
272
+ ]
273
+ charts = described_class.analyze(data)
274
+ # scatter would require 2 measure cols — user_id should be excluded
275
+ scatter = charts.find { |c| c[:type] == "scatter" }
276
+ if scatter
277
+ labels = scatter[:datasets].map { |d| d[:label] }
278
+ expect(labels.join).not_to match(/user.id/i)
279
+ end
280
+ end
281
+ end
282
+
283
+ # ── expand_month_range ────────────────────────────────────────────────────
284
+
285
+ describe "month range expansion" do
286
+ it "generates all months between two YYYY-MM strings" do
287
+ data = [
288
+ { "month" => "2023-11", "n" => 1 },
289
+ { "month" => "2024-01", "n" => 2 }
290
+ ]
291
+ line = described_class.analyze(data).find { |c| c[:type] == "line" }
292
+ expect(line).not_to be_nil
293
+ expect(line[:labels]).to include("2023-11", "2023-12", "2024-01")
294
+ end
295
+ end
296
+ end