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
data/README.md
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./.github/assets/glancer-banner.svg" alt="Glancer" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong>Natural language database queries for your Rails app — powered by RAG and LLMs.</strong>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://github.com/ErnaneJ/glancer/actions/workflows/ci.yml">
|
|
11
|
+
<img src="https://github.com/ErnaneJ/glancer/actions/workflows/ci.yml/badge.svg" alt="CI">
|
|
12
|
+
</a>
|
|
13
|
+
<a href="https://github.com/ErnaneJ/glancer">
|
|
14
|
+
<img src="https://github.com/ErnaneJ/glancer/raw/refs/heads/badge-generator/.github/badges/coverage.svg" alt="Coverage">
|
|
15
|
+
</a>
|
|
16
|
+
<a href="https://rubygems.org/gems/glancer">
|
|
17
|
+
<img src="https://badge.fury.io/rb/glancer.svg" alt="Gem Version">
|
|
18
|
+
</a>
|
|
19
|
+
<a href="LICENSE.txt">
|
|
20
|
+
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT">
|
|
21
|
+
</a>
|
|
22
|
+
<a href="https://www.ruby-lang.org/en/">
|
|
23
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.3-CC342D" alt="Ruby >= 3.3">
|
|
24
|
+
</a>
|
|
25
|
+
<a href="https://rubyonrails.org/">
|
|
26
|
+
<img src="https://img.shields.io/badge/rails-%3E%3D%207.0-CC0000" alt="Rails >= 7.0">
|
|
27
|
+
</a>
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
Glancer is a **Ruby on Rails engine** that mounts a full chat interface inside your app and lets anyone on your team query the database in plain language, no SQL required. You ask a question, Glancer retrieves the relevant schema context, generates a query, validates and executes it safely, then returns the results with a human-readable explanation.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
"How many orders were placed in the last 30 days, grouped by status?"
|
|
36
|
+
→ SELECT executed, results shown, answer written in plain language.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
[](https://github.com/ErnaneJ/glancer/raw/refs/heads/main/.github/assets/demo.mp4)
|
|
40
|
+
> Click to see demo. ☝
|
|
41
|
+
|
|
42
|
+
## Why Glancer?
|
|
43
|
+
|
|
44
|
+
Every Rails app accumulates tables and columns whose meaning lives in the heads of a few engineers. Product managers open tickets for simple questions. Data teams copy-paste schemas into ChatGPT. Engineers write one-off queries for stakeholders.
|
|
45
|
+
|
|
46
|
+
Glancer removes that friction. It gives your app a persistent, context-aware database assistant that understands your domain,not just generic SQL, because it is taught your schema, your models, and your business rules through a plain Markdown file.
|
|
47
|
+
|
|
48
|
+
**Key design decisions:**
|
|
49
|
+
|
|
50
|
+
- **Safety first** — all queries run inside a transaction that always rolls back. No write statement can ever reach the database.
|
|
51
|
+
- **Your LLM, your cost** — bring your own Gemini, OpenAI, or OpenRouter key. Mix providers per role to balance cost and quality.
|
|
52
|
+
- **No external vector store** — embeddings live in your existing database. No Pinecone, no Weaviate, no extra infrastructure.
|
|
53
|
+
- **Rails-native** — mounted as an engine, uses Turbo and Stimulus, installs in under five minutes.
|
|
54
|
+
- **Dual query modes** — generate raw `SELECT` statements or Ruby ActiveRecord expressions depending on what fits your domain better.
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
| Dependency | Minimum version |
|
|
59
|
+
|---|---|
|
|
60
|
+
| Ruby | 3.3 |
|
|
61
|
+
| Rails | 7.0 |
|
|
62
|
+
| Database | SQLite, PostgreSQL, or MySQL / MariaDB |
|
|
63
|
+
| LLM provider | Gemini, OpenAI, or OpenRouter API key |
|
|
64
|
+
|
|
65
|
+
Glancer is built on top of [**ruby_llm**](https://github.com/crmne/ruby_llm), a provider-agnostic LLM client for Ruby. All LLM calls (query generation, humanized responses, embeddings, and optional question enrichment) go through ruby_llm, so any model it supports works with Glancer.
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
### 1. Add to your Gemfile
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
gem "glancer"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bundle install
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 2. Run the install generator
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
rails generate glancer:install
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This creates:
|
|
86
|
+
|
|
87
|
+
- `config/initializers/glancer.rb` — your main configuration file
|
|
88
|
+
- `config/glancer/llm_context.glancer.md` — optional domain context written in Markdown
|
|
89
|
+
- Mounts the engine at `/glancer` in `config/routes.rb`
|
|
90
|
+
|
|
91
|
+
### 3. Migrate the database
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
rails db:migrate
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 4. Index your schema
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
rails glancer:index:all
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 5. Start asking questions
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
http://localhost:3000/glancer
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
Edit `config/initializers/glancer.rb`. Minimal working setup:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
Glancer.configure do |config|
|
|
115
|
+
config.llm_provider = :gemini
|
|
116
|
+
config.llm_model = "gemini-2.0-flash"
|
|
117
|
+
config.gemini_api_key = ENV["GEMINI_API_KEY"]
|
|
118
|
+
|
|
119
|
+
config.schema_permission = true # allow indexing db/schema.rb
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Query mode
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
config.query_mode = :sql # (default) LLM generates a SELECT statement
|
|
127
|
+
config.query_mode = :activerecord # LLM generates a Ruby/ActiveRecord expression
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
ActiveRecord mode lets the LLM leverage model scopes, named associations, and Ruby idioms. SQL mode is more portable and works without any models loaded.
|
|
131
|
+
|
|
132
|
+
### Split providers per role
|
|
133
|
+
|
|
134
|
+
Different models can handle different responsibilities. This is the recommended setup for cost optimization:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
Glancer.configure do |config|
|
|
138
|
+
config.llm_provider = :gemini # fallback for any unspecified role
|
|
139
|
+
|
|
140
|
+
# A capable model for accurate query generation
|
|
141
|
+
config.code_provider = :openai
|
|
142
|
+
config.code_model = "gpt-4o"
|
|
143
|
+
|
|
144
|
+
# A cheaper model for writing human-readable responses
|
|
145
|
+
config.chat_provider = :gemini
|
|
146
|
+
config.chat_model = "gemini-2.0-flash"
|
|
147
|
+
|
|
148
|
+
# Embedding model
|
|
149
|
+
config.embedding_provider = :gemini
|
|
150
|
+
config.embedding_model = "text-embedding-004"
|
|
151
|
+
|
|
152
|
+
# Optional: separate model to enrich ambiguous questions before retrieval
|
|
153
|
+
config.query_enrichment_enabled = true
|
|
154
|
+
config.enrichment_provider = :gemini
|
|
155
|
+
config.enrichment_model = "gemini-2.0-flash"
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Read-only replica
|
|
160
|
+
|
|
161
|
+
Route all queries to a replica to offload your primary database:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
config.read_only_db = ENV["REPLICA_DATABASE_URL"]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Full configuration reference
|
|
168
|
+
|
|
169
|
+
| Option | Default | Description |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `adapter` | auto-detected | `:postgres`, `:mysql`, `:mysql2`, or `:sqlite` |
|
|
172
|
+
| `query_mode` | `:sql` | `:sql` (raw SELECT) or `:activerecord` (Ruby expression) |
|
|
173
|
+
| `read_only_db` | `nil` | Replica connection URL |
|
|
174
|
+
| `statement_timeout` | `30.seconds` | Max execution time; enforced server-side on PG and MySQL |
|
|
175
|
+
| `llm_provider` | `:gemini` | Default provider for all roles (`:gemini`, `:openai`, `:openrouter`) |
|
|
176
|
+
| `llm_model` | `"gemini-2.0-flash"` | Default model for all roles |
|
|
177
|
+
| `code_provider` / `code_model` | inherits default | Provider/model for query generation |
|
|
178
|
+
| `chat_provider` / `chat_model` | inherits default | Provider/model for humanized responses |
|
|
179
|
+
| `embedding_provider` / `embedding_model` | inherits default | Provider/model for embeddings |
|
|
180
|
+
| `enrichment_provider` / `enrichment_model` | inherits default | Provider/model for question enrichment |
|
|
181
|
+
| `query_enrichment_enabled` | `false` | Pre-retrieval question rewriting to inject table hints |
|
|
182
|
+
| `gemini_api_key` | `nil` | Gemini API key |
|
|
183
|
+
| `openai_api_key` | `nil` | OpenAI API key |
|
|
184
|
+
| `openrouter_api_key` | `nil` | OpenRouter API key |
|
|
185
|
+
| `schema_permission` | `false` | Index `db/schema.rb` |
|
|
186
|
+
| `models_permission` | `false` | Index `app/models/**/*.rb` |
|
|
187
|
+
| `context_file_path` | `"config/glancer/llm_context.glancer.md"` | Custom domain context file |
|
|
188
|
+
| `chunk_size` | `1000` | Max characters per embedding chunk |
|
|
189
|
+
| `chunk_overlap` | `150` | Overlap between consecutive chunks |
|
|
190
|
+
| `k` | `5` | Top-k chunks retrieved per question |
|
|
191
|
+
| `min_score` | `0.6` | Minimum cosine similarity score (0.0–1.0) |
|
|
192
|
+
| `schema_documents_weight` | `1.3` | Score boost for schema chunks |
|
|
193
|
+
| `context_documents_weight` | `1.2` | Score boost for context chunks |
|
|
194
|
+
| `models_documents_weight` | `1.1` | Score boost for model chunks |
|
|
195
|
+
| `history_limit` | `6` | Prior conversation turns included in the LLM prompt |
|
|
196
|
+
| `workflow_cache_ttl` | `5.minutes` | In-memory result cache TTL; `0` to disable |
|
|
197
|
+
| `log_verbosity` | `:info` | `:silent`, `:none`, `:info`, or `:debug` |
|
|
198
|
+
| `log_output_path` | `nil` | Log file path; `nil` writes to stdout |
|
|
199
|
+
| `blazer_path` | `nil` | Blazer base path; auto-detected when `blazer` gem is present |
|
|
200
|
+
|
|
201
|
+
## Indexing
|
|
202
|
+
|
|
203
|
+
Glancer embeds your schema, models, and custom context into the `glancer_embeddings` table. Re-run indexing whenever the schema changes significantly.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
rails glancer:index:all # Schema + models + context (prompts confirmation)
|
|
207
|
+
rails glancer:index:schema # db/schema.rb only
|
|
208
|
+
rails glancer:index:models # app/models/**/*.rb only
|
|
209
|
+
rails glancer:index:context # Custom context Markdown file only
|
|
210
|
+
rails glancer:version # Print the installed gem version
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The schema indexer automatically enriches each table chunk with model association metadata (has_many, belongs_to, etc.) and generates a dedicated foreign key chunk, so the LLM understands relationships without you having to describe them manually.
|
|
214
|
+
|
|
215
|
+
### Custom context file
|
|
216
|
+
|
|
217
|
+
`config/glancer/llm_context.glancer.md` is where you document domain knowledge that lives outside the schema — enum values, business definitions, metric formulas, naming conventions:
|
|
218
|
+
|
|
219
|
+
```markdown
|
|
220
|
+
# Domain context
|
|
221
|
+
|
|
222
|
+
- `orders.status` values: "pending" | "paid" | "shipped" | "refunded".
|
|
223
|
+
- `users.role` can be "admin", "agent", or "customer". Admins are excluded from retention metrics.
|
|
224
|
+
- Monthly revenue = SUM(orders.total) WHERE status = "paid".
|
|
225
|
+
- When asked about "churn", use the `churned_at` column on the `subscriptions` table.
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Add `--glancer-ignore` as the **first line** of the file to exclude it from indexing.
|
|
229
|
+
|
|
230
|
+
## Chat interface
|
|
231
|
+
|
|
232
|
+
Visit `/glancer` in your browser.
|
|
233
|
+
|
|
234
|
+
- **Async processing** — messages are handled in a background thread; the UI polls for completion so you can open a new chat while another query runs.
|
|
235
|
+
- **Step labels** — the interface shows what the pipeline is doing: enriching, retrieving context, generating code, validating, executing, preparing response.
|
|
236
|
+
- **@mention autocomplete** — type `@table_name` to pin a specific table to your question; it renders as a chip linked to the schema viewer.
|
|
237
|
+
- **Dual query modes** — generated SQL or Ruby is syntax-highlighted in the response.
|
|
238
|
+
- **Inline code editing** — modify the generated query and re-run it without asking a new question. Edited versions show a badge.
|
|
239
|
+
- **Results table** — with one-click CSV export (client-side, no backend endpoint).
|
|
240
|
+
- **Charts** — bar, line, doughnut, and scatter charts are auto-generated from the result set where meaningful.
|
|
241
|
+
- **Fullscreen charts** — expand any chart to a fullscreen dialog for detailed inspection.
|
|
242
|
+
- **Blazer integration** — open the SQL in Blazer pre-filled, if the gem is installed.
|
|
243
|
+
- **Audio input** — click the microphone to dictate your question.
|
|
244
|
+
- **Multi-language** — ask in any language; the LLM responds in the same language.
|
|
245
|
+
- **Custom instructions** — set persistent system instructions at `/glancer/settings`.
|
|
246
|
+
- **Schema viewer** — browse all indexed tables and columns at `/glancer/db-schema`.
|
|
247
|
+
- **Message details panel** — shows generated code, edit history, execution audit, sources used, and enriched question.
|
|
248
|
+
|
|
249
|
+
## Safety
|
|
250
|
+
|
|
251
|
+
Glancer is designed to be safe on production databases.
|
|
252
|
+
|
|
253
|
+
### SQL mode
|
|
254
|
+
|
|
255
|
+
| Layer | Mechanism |
|
|
256
|
+
|---|---|
|
|
257
|
+
| **No writes** | All queries run inside a transaction that unconditionally rolls back |
|
|
258
|
+
| **Keyword blocklist** | `DELETE`, `UPDATE`, `INSERT`, `DROP`, `TRUNCATE`, `ALTER`, `CREATE`, `REPLACE` are rejected before execution |
|
|
259
|
+
| **Table validation** | Referenced tables are checked against the indexed schema; unknown tables return a friendly error |
|
|
260
|
+
| **Statement timeout** | `statement_timeout` (PG) / `max_execution_time` (MySQL) kills runaway queries server-side |
|
|
261
|
+
| **Audit trail** | Every execution is recorded in `glancer_audits` with a unique `run_id` UUID |
|
|
262
|
+
| **Replica support** | Route queries to a read-only replica via `config.read_only_db` |
|
|
263
|
+
|
|
264
|
+
### ActiveRecord mode
|
|
265
|
+
|
|
266
|
+
| Layer | Mechanism |
|
|
267
|
+
|---|---|
|
|
268
|
+
| **No writes** | Same rolled-back transaction as SQL mode |
|
|
269
|
+
| **Method blocklist** | `.destroy`, `.delete`, `.update`, `.save`, `.create`, `.insert`, `.upsert`, `.touch` and variants are rejected |
|
|
270
|
+
| **Shell blocklist** | Backticks, `system()`, `exec()`, `spawn()` are rejected |
|
|
271
|
+
| **Eval blocklist** | `eval`, `instance_eval`, `class_eval` are rejected |
|
|
272
|
+
| **File write blocklist** | `FileUtils`, `File.write`, `IO.write` are rejected |
|
|
273
|
+
| **Dynamic load blocklist** | `require`, `load`, `autoload` are rejected |
|
|
274
|
+
| **Audit trail** | Same as SQL mode; `code_type: "activerecord"` recorded in `glancer_audits` |
|
|
275
|
+
|
|
276
|
+
## Internal database tables
|
|
277
|
+
|
|
278
|
+
| Table | Purpose |
|
|
279
|
+
|---|---|
|
|
280
|
+
| `glancer_chats` | Conversation containers |
|
|
281
|
+
| `glancer_messages` | User/assistant turns; stores generated code, code type, and processing status |
|
|
282
|
+
| `glancer_embeddings` | Vector store: content, embedding (JSONB on PG / JSON elsewhere), source type and path |
|
|
283
|
+
| `glancer_audits` | Immutable query log with unique `run_id` per execution |
|
|
284
|
+
| `glancer_code_versions` | Code edit history per message |
|
|
285
|
+
| `glancer_settings` | Runtime configuration (e.g. custom instructions) |
|
|
286
|
+
|
|
287
|
+
## Usage from Ruby
|
|
288
|
+
|
|
289
|
+
You can call Glancer's internals directly from the Rails console or your own code:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
# Re-index everything
|
|
293
|
+
Glancer::Indexer.rebuild_all!
|
|
294
|
+
|
|
295
|
+
# Run the full pipeline
|
|
296
|
+
result = Glancer::Workflow.run(chat.id, "Which products have never been ordered?")
|
|
297
|
+
# => { content: "...", code: "SELECT ...", code_type: "sql", successful: true, sources: [...] }
|
|
298
|
+
|
|
299
|
+
# Retrieve relevant chunks for a question (without running the full pipeline)
|
|
300
|
+
chunks = Glancer::Retriever.search("monthly revenue by region")
|
|
301
|
+
|
|
302
|
+
# Check SQL against the safety layer
|
|
303
|
+
Glancer::Workflow::SQLSanitizer.ensure_safe!("SELECT * FROM users")
|
|
304
|
+
|
|
305
|
+
# Check an ActiveRecord expression against the safety layer
|
|
306
|
+
Glancer::Workflow::ARSanitizer.ensure_safe!("User.where(active: true).count")
|
|
307
|
+
|
|
308
|
+
# Validate table references against the indexed schema
|
|
309
|
+
Glancer::Workflow::SQLValidator.validate_tables_exist!("SELECT * FROM orders JOIN unknown_table")
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Development
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
git clone https://github.com/ErnaneJ/glancer
|
|
316
|
+
cd glancer
|
|
317
|
+
bundle install
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
bundle exec rake # Tests + RuboCop (mirrors CI)
|
|
322
|
+
bundle exec rake spec # RSpec only
|
|
323
|
+
bundle exec rake rubocop # RuboCop only
|
|
324
|
+
|
|
325
|
+
# Run a single spec file
|
|
326
|
+
bundle exec rspec spec/lib/glancer/workflow/executor_spec.rb
|
|
327
|
+
|
|
328
|
+
# Run tests with coverage report
|
|
329
|
+
COVERAGE=1 bundle exec rspec
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
To iterate against a host Rails app, point the Gemfile to the local path:
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
gem "glancer", path: "../glancer"
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Contributing
|
|
339
|
+
|
|
340
|
+
Bug reports, feature requests, and pull requests are welcome on [GitHub](https://github.com/ErnaneJ/glancer).
|
|
341
|
+
|
|
342
|
+
Before opening a pull request:
|
|
343
|
+
|
|
344
|
+
1. Fork the repository and create a feature branch from `main`.
|
|
345
|
+
2. Write or update tests for your changes — `bundle exec rake spec` must stay green.
|
|
346
|
+
3. Ensure RuboCop is clean — `bundle exec rake rubocop`.
|
|
347
|
+
4. Add an entry to `CHANGELOG.md` under `[Unreleased]`.
|
|
348
|
+
5. Open a pull request with a clear description of what changed and why.
|
|
349
|
+
|
|
350
|
+
Please read the [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
|
|
351
|
+
|
|
352
|
+
## License
|
|
353
|
+
|
|
354
|
+
Glancer is available as open source under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//= link glancer/application.js
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Turbo } from "@hotwired/turbo-rails"
|
|
2
|
+
import * as Stimulus from "@hotwired/stimulus"
|
|
3
|
+
|
|
4
|
+
window.Turbo = Turbo;
|
|
5
|
+
|
|
6
|
+
import ChatController from "./controllers/chat_controller";
|
|
7
|
+
import MessageController from "./controllers/message_controller";
|
|
8
|
+
import ToastController from "./controllers/toast_controller";
|
|
9
|
+
|
|
10
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
11
|
+
const application = Stimulus.Application.start();
|
|
12
|
+
application.register("chat", ChatController);
|
|
13
|
+
application.register("message", MessageController);
|
|
14
|
+
application.register("toast", ToastController);
|
|
15
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
connect() {
|
|
5
|
+
// Restore desktop sidebar state from localStorage
|
|
6
|
+
const sidebarState = localStorage.getItem("glancer-sidebar-desktop");
|
|
7
|
+
if (sidebarState === "closed") {
|
|
8
|
+
this._collapseSidebar(false);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
create(event) {
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
Turbo.visit(event.currentTarget.href);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
select(event) {
|
|
18
|
+
event.preventDefault();
|
|
19
|
+
const chatId = event.currentTarget.dataset.chatId;
|
|
20
|
+
this.closeSidebar();
|
|
21
|
+
Turbo.visit(`/glancer/chats/${chatId}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
copy(event) {
|
|
25
|
+
const content = event.currentTarget.dataset.message;
|
|
26
|
+
navigator.clipboard.writeText(content)
|
|
27
|
+
.then(() => this.toast("Copiado", "success"))
|
|
28
|
+
.catch(() => this.toast("Falha ao copiar", "error"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
toggleTheme() {
|
|
32
|
+
const isDark = document.documentElement.classList.toggle("dark");
|
|
33
|
+
localStorage.setItem("glancer-theme", isDark ? "dark" : "light");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Mobile sidebar ───────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
openSidebar() {
|
|
39
|
+
const sidebar = document.getElementById("sidebar");
|
|
40
|
+
const overlay = document.getElementById("sidebar-overlay");
|
|
41
|
+
sidebar?.classList.remove("-translate-x-full");
|
|
42
|
+
overlay?.classList.remove("hidden");
|
|
43
|
+
document.body.style.overflow = "hidden";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
closeSidebar() {
|
|
47
|
+
const sidebar = document.getElementById("sidebar");
|
|
48
|
+
const overlay = document.getElementById("sidebar-overlay");
|
|
49
|
+
sidebar?.classList.add("-translate-x-full");
|
|
50
|
+
overlay?.classList.add("hidden");
|
|
51
|
+
document.body.style.overflow = "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Desktop sidebar toggle ───────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
toggleDesktopSidebar() {
|
|
57
|
+
const sidebar = document.getElementById("sidebar");
|
|
58
|
+
if (!sidebar) return;
|
|
59
|
+
|
|
60
|
+
const isCollapsed = sidebar.classList.contains("lg:w-0");
|
|
61
|
+
if (isCollapsed) {
|
|
62
|
+
this._expandSidebar();
|
|
63
|
+
} else {
|
|
64
|
+
this._collapseSidebar(true);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_collapseSidebar(persist = true) {
|
|
69
|
+
const sidebar = document.getElementById("sidebar");
|
|
70
|
+
const expandBtn = document.getElementById("sidebar-expand-btn");
|
|
71
|
+
sidebar?.classList.add("lg:w-0", "lg:overflow-hidden", "lg:min-w-0");
|
|
72
|
+
sidebar?.classList.remove("lg:w-64", "lg:translate-x-0");
|
|
73
|
+
if (expandBtn) {
|
|
74
|
+
expandBtn.classList.remove("hidden");
|
|
75
|
+
expandBtn.removeAttribute("aria-hidden");
|
|
76
|
+
}
|
|
77
|
+
if (persist) localStorage.setItem("glancer-sidebar-desktop", "closed");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_expandSidebar() {
|
|
81
|
+
const sidebar = document.getElementById("sidebar");
|
|
82
|
+
const expandBtn = document.getElementById("sidebar-expand-btn");
|
|
83
|
+
sidebar?.classList.remove("lg:w-0", "lg:overflow-hidden", "lg:min-w-0");
|
|
84
|
+
sidebar?.classList.add("lg:w-64", "lg:translate-x-0");
|
|
85
|
+
if (expandBtn) {
|
|
86
|
+
expandBtn.classList.add("hidden");
|
|
87
|
+
expandBtn.setAttribute("aria-hidden", "true");
|
|
88
|
+
}
|
|
89
|
+
localStorage.setItem("glancer-sidebar-desktop", "open");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Toast ────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
toast(message, type = "info") {
|
|
95
|
+
document.dispatchEvent(new CustomEvent("glancer:toast", { detail: { message, type } }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get csrfToken() {
|
|
99
|
+
return document.querySelector("[name='csrf-token']")?.content ?? "";
|
|
100
|
+
}
|
|
101
|
+
}
|