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,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::SQLValidator do
|
|
6
|
+
before do
|
|
7
|
+
# Seed a schema embedding so the validator can find the "users" table.
|
|
8
|
+
Glancer::Embedding.create!(
|
|
9
|
+
content: "create_table users ...",
|
|
10
|
+
embedding: [],
|
|
11
|
+
source_type: "schema",
|
|
12
|
+
source_path: "db/schema.rb#users"
|
|
13
|
+
)
|
|
14
|
+
Glancer::Embedding.create!(
|
|
15
|
+
content: "create_table orders ...",
|
|
16
|
+
embedding: [],
|
|
17
|
+
source_type: "schema",
|
|
18
|
+
source_path: "db/schema.rb#orders"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# ── validate_tables_exist! ────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe ".validate_tables_exist!" do
|
|
25
|
+
it "does not raise when all tables in the SQL are indexed" do
|
|
26
|
+
sql = "SELECT * FROM users"
|
|
27
|
+
expect { described_class.validate_tables_exist!(sql) }.not_to raise_error
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "does not raise for a query referencing an indexed table via FROM" do
|
|
31
|
+
sql = "SELECT u.id FROM users u"
|
|
32
|
+
expect { described_class.validate_tables_exist!(sql) }.not_to raise_error
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "does not raise for a subquery referencing two indexed tables" do
|
|
36
|
+
sql = "SELECT * FROM orders WHERE user_id IN (SELECT id FROM users)"
|
|
37
|
+
expect { described_class.validate_tables_exist!(sql) }.not_to raise_error
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "raises Glancer::Error when a table is not indexed" do
|
|
41
|
+
sql = "SELECT * FROM missing_table"
|
|
42
|
+
expect { described_class.validate_tables_exist!(sql) }
|
|
43
|
+
.to raise_error(Glancer::Error, /missing_table/)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "raises Glancer::Error wrapping the underlying message" do
|
|
47
|
+
sql = "SELECT * FROM ghost_table"
|
|
48
|
+
expect { described_class.validate_tables_exist!(sql) }
|
|
49
|
+
.to raise_error(Glancer::Error)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "does not raise for system schema tables (sqlite_master)" do
|
|
53
|
+
sql = "SELECT * FROM sqlite_master"
|
|
54
|
+
expect { described_class.validate_tables_exist!(sql) }.not_to raise_error
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ── extract_table_names ───────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe ".extract_table_names" do
|
|
61
|
+
it "extracts a single table name" do
|
|
62
|
+
result = described_class.extract_table_names("SELECT * FROM users")
|
|
63
|
+
expect(result).to contain_exactly("users")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "extracts the FROM table (JOIN tables are not captured by the FROM regex)" do
|
|
67
|
+
# extract_table_names only scans for 'from tablename' patterns.
|
|
68
|
+
# JOIN keyword tables are NOT extracted because they have no FROM before them.
|
|
69
|
+
sql = "SELECT * FROM orders JOIN users ON orders.user_id = users.id"
|
|
70
|
+
result = described_class.extract_table_names(sql)
|
|
71
|
+
expect(result).to include("orders")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "extracts tables from multiple FROM clauses (e.g. subqueries)" do
|
|
75
|
+
sql = "SELECT * FROM orders WHERE user_id IN (SELECT id FROM users)"
|
|
76
|
+
result = described_class.extract_table_names(sql)
|
|
77
|
+
expect(result).to include("orders", "users")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "extracts table names from subqueries" do
|
|
81
|
+
sql = "SELECT * FROM (SELECT id FROM products) sub"
|
|
82
|
+
result = described_class.extract_table_names(sql)
|
|
83
|
+
expect(result).to include("products")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "is case-insensitive for the FROM keyword" do
|
|
87
|
+
result = described_class.extract_table_names("SELECT * from users")
|
|
88
|
+
expect(result).to include("users")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "handles quoted table names by stripping quotes" do
|
|
92
|
+
result = described_class.extract_table_names('SELECT * FROM "users"')
|
|
93
|
+
expect(result).to include("users")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "downcases table names" do
|
|
97
|
+
result = described_class.extract_table_names("SELECT * FROM Users")
|
|
98
|
+
expect(result).to include("users")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "returns unique table names" do
|
|
102
|
+
sql = "SELECT * FROM users JOIN users u2 ON users.id = u2.id"
|
|
103
|
+
result = described_class.extract_table_names(sql)
|
|
104
|
+
expect(result.count("users")).to eq(1)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ── system_table? ─────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe ".system_table?" do
|
|
111
|
+
it "returns true for sqlite_master when adapter is sqlite" do
|
|
112
|
+
expect(described_class.system_table?("sqlite_master")).to be(true)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "returns false for a user table" do
|
|
116
|
+
expect(described_class.system_table?("orders")).to be(false)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "returns true for a schema-qualified system table" do
|
|
120
|
+
Glancer.configuration.adapter = :postgres
|
|
121
|
+
expect(described_class.system_table?("information_schema.tables")).to be(true)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "returns true for pg_catalog when adapter is postgres" do
|
|
125
|
+
Glancer.configuration.adapter = :postgres
|
|
126
|
+
expect(described_class.system_table?("pg_catalog")).to be(true)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "returns true for information_schema when adapter is mysql2" do
|
|
130
|
+
Glancer.configuration.adapter = :mysql2
|
|
131
|
+
expect(described_class.system_table?("information_schema")).to be(true)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "returns false for a user table with mysql2 adapter" do
|
|
135
|
+
Glancer.configuration.adapter = :mysql2
|
|
136
|
+
expect(described_class.system_table?("orders")).to be(false)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "returns false for a user table with an unknown adapter" do
|
|
140
|
+
allow(Glancer.configuration).to receive(:resolved_adapter).and_return("unknown_db")
|
|
141
|
+
expect(described_class.system_table?("orders")).to be(false)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# ── indexed_schema_table_names ────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe ".indexed_schema_table_names" do
|
|
148
|
+
it "returns table names from schema embeddings" do
|
|
149
|
+
result = described_class.indexed_schema_table_names
|
|
150
|
+
expect(result).to include("users", "orders")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "returns an empty array when no schema embeddings exist" do
|
|
154
|
+
Glancer::Embedding.where(source_type: "schema").delete_all
|
|
155
|
+
expect(described_class.indexed_schema_table_names).to eq([])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "ignores non-schema embeddings" do
|
|
159
|
+
Glancer::Embedding.create!(
|
|
160
|
+
content: "model content", embedding: [], source_type: "models", source_path: "user.rb"
|
|
161
|
+
)
|
|
162
|
+
result = described_class.indexed_schema_table_names
|
|
163
|
+
expect(result).not_to include(nil)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow do
|
|
6
|
+
# ── Common doubles ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
let(:question) { "How many users are there?" }
|
|
9
|
+
let(:sql) { "SELECT COUNT(*) AS cnt FROM users" }
|
|
10
|
+
let(:chat) { Glancer::Chat.create!(title: "Test Chat") }
|
|
11
|
+
|
|
12
|
+
let(:embedding) do
|
|
13
|
+
Glancer::Embedding.create!(
|
|
14
|
+
content: "create_table users ...",
|
|
15
|
+
embedding: [0.1, 0.2, 0.3],
|
|
16
|
+
source_type: "schema",
|
|
17
|
+
source_path: "db/schema.rb#users"
|
|
18
|
+
).tap do |e|
|
|
19
|
+
# Workflow.run calls e.score on each embedding from Retriever.search.
|
|
20
|
+
# Retriever.search attaches score via define_singleton_method; add it here too.
|
|
21
|
+
e.define_singleton_method(:score) { 1.3 }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
let(:fake_embed_response) { double("EmbedResponse", vectors: [0.1, 0.2, 0.3]) }
|
|
26
|
+
let(:fake_sql_response) { double("SqlResponse", content: "```sql\n#{sql}\n```") }
|
|
27
|
+
let(:fake_chat_response) { double("ChatResponse", content: "There are 42 users.") }
|
|
28
|
+
|
|
29
|
+
let(:fake_llm_chat) do
|
|
30
|
+
double("Chat").tap do |c|
|
|
31
|
+
allow(c).to receive(:ask).and_return(fake_sql_response)
|
|
32
|
+
allow(c).to receive(:with_instructions).and_return(c)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
let(:fake_humanize_chat) do
|
|
37
|
+
double("HumanizeChat").tap do |c|
|
|
38
|
+
allow(c).to receive(:ask).and_return(fake_chat_response)
|
|
39
|
+
allow(c).to receive(:with_instructions).and_return(c)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
before do
|
|
44
|
+
embedding # ensure it is persisted
|
|
45
|
+
|
|
46
|
+
allow(RubyLLM).to receive(:embed).and_return(fake_embed_response)
|
|
47
|
+
allow(RubyLLM).to receive(:chat).and_return(fake_llm_chat)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Helper to stub the humanized response separately
|
|
51
|
+
def stub_humanize_separately
|
|
52
|
+
call_count = 0
|
|
53
|
+
allow(RubyLLM).to receive(:chat) do
|
|
54
|
+
call_count += 1
|
|
55
|
+
call_count == 1 ? fake_llm_chat : fake_humanize_chat
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ── Cache hit ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe ".run — cache hit" do
|
|
62
|
+
before do
|
|
63
|
+
Glancer::Workflow::Cache.write(question, {
|
|
64
|
+
question: question,
|
|
65
|
+
content: "Cached answer",
|
|
66
|
+
code: sql,
|
|
67
|
+
code_type: "sql",
|
|
68
|
+
successful: true
|
|
69
|
+
})
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "returns the cached result when cache: true (default)" do
|
|
73
|
+
result = described_class.run(chat.id, question, cache: true)
|
|
74
|
+
expect(result[:content]).to eq("Cached answer")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "includes from_cache: true in the returned hash" do
|
|
78
|
+
result = described_class.run(chat.id, question, cache: true)
|
|
79
|
+
expect(result[:from_cache]).to be(true)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "skips the LLM entirely when a cache hit is available" do
|
|
83
|
+
expect(RubyLLM).not_to receive(:chat)
|
|
84
|
+
described_class.run(chat.id, question, cache: true)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "bypasses the cache when cache: false" do
|
|
88
|
+
# cache: false → LLM is called
|
|
89
|
+
expect(Glancer::Retriever).to receive(:search).and_return([embedding])
|
|
90
|
+
allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return(sql)
|
|
91
|
+
allow(Glancer::Workflow::SQLValidator).to receive(:validate_tables_exist!)
|
|
92
|
+
allow(Glancer::Workflow::Executor).to receive(:execute).and_return([{ "cnt" => 42 }])
|
|
93
|
+
allow(Glancer::Workflow::LLM).to receive(:humanized_response).and_return("42 users")
|
|
94
|
+
|
|
95
|
+
result = described_class.run(chat.id, question, cache: false)
|
|
96
|
+
expect(result[:from_cache]).to be_nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ── Full happy path ───────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe ".run — full pipeline" do
|
|
103
|
+
before do
|
|
104
|
+
# Seed the users table in the schema embeddings so validation passes
|
|
105
|
+
Glancer::Workflow::Cache.clear
|
|
106
|
+
|
|
107
|
+
allow(Glancer::Retriever).to receive(:search).and_return([embedding])
|
|
108
|
+
allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return("```sql\n#{sql}\n```")
|
|
109
|
+
allow(Glancer::Workflow::SQLValidator).to receive(:validate_tables_exist!)
|
|
110
|
+
allow(Glancer::Workflow::Executor).to receive(:execute).and_return([{ "cnt" => 42 }])
|
|
111
|
+
allow(Glancer::Workflow::LLM).to receive(:humanized_response).and_return("There are 42 users.")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "returns a hash with :question" do
|
|
115
|
+
result = described_class.run(chat.id, question)
|
|
116
|
+
expect(result[:question]).to eq(question)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "returns :successful true on success" do
|
|
120
|
+
result = described_class.run(chat.id, question)
|
|
121
|
+
expect(result[:successful]).to be(true)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "returns :code with the extracted SQL" do
|
|
125
|
+
result = described_class.run(chat.id, question)
|
|
126
|
+
expect(result[:code]).to include("SELECT")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "returns :content with the humanized response" do
|
|
130
|
+
result = described_class.run(chat.id, question)
|
|
131
|
+
expect(result[:content]).to eq("There are 42 users.")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "returns :sources array from the embeddings" do
|
|
135
|
+
result = described_class.run(chat.id, question)
|
|
136
|
+
expect(result[:sources]).to be_an(Array)
|
|
137
|
+
expect(result[:sources].first).to have_key(:type)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "writes the result to cache when cache: true" do
|
|
141
|
+
described_class.run(chat.id, question, cache: true)
|
|
142
|
+
cached = Glancer::Workflow::Cache.fetch(question)
|
|
143
|
+
expect(cached).not_to be_nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "does not write to cache when cache: false" do
|
|
147
|
+
described_class.run(chat.id, question, cache: false)
|
|
148
|
+
expect(Glancer::Workflow::Cache.fetch(question)).to be_nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ── Table validation failure ───────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe ".run — table validation failure" do
|
|
155
|
+
before do
|
|
156
|
+
Glancer::Workflow::Cache.clear
|
|
157
|
+
|
|
158
|
+
allow(Glancer::Retriever).to receive(:search).and_return([embedding])
|
|
159
|
+
allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return(sql)
|
|
160
|
+
allow(Glancer::Workflow::SQLValidator)
|
|
161
|
+
.to receive(:validate_tables_exist!)
|
|
162
|
+
.and_raise(Glancer::Error, "Missing table(s) in indexed schema: users")
|
|
163
|
+
allow(Glancer::Workflow::LLM)
|
|
164
|
+
.to receive(:explain_missing_tables)
|
|
165
|
+
.and_return("The table 'users' was not found in the schema.")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "returns :successful false" do
|
|
169
|
+
result = described_class.run(chat.id, question)
|
|
170
|
+
expect(result[:successful]).to be(false)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "returns an explanation in :content" do
|
|
174
|
+
result = described_class.run(chat.id, question)
|
|
175
|
+
expect(result[:content]).to include("users")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "includes the code that failed validation" do
|
|
179
|
+
result = described_class.run(chat.id, question)
|
|
180
|
+
expect(result[:code]).to be_a(String)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ── Execution failure ─────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
describe ".run — execution failure after retries" do
|
|
187
|
+
before do
|
|
188
|
+
Glancer::Workflow::Cache.clear
|
|
189
|
+
|
|
190
|
+
allow(Glancer::Retriever).to receive(:search).and_return([embedding])
|
|
191
|
+
allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return(sql)
|
|
192
|
+
allow(Glancer::Workflow::SQLValidator).to receive(:validate_tables_exist!)
|
|
193
|
+
allow(Glancer::Workflow::Executor).to receive(:execute).and_return(
|
|
194
|
+
{ error: true, message: "no such table: users", last_code: sql }
|
|
195
|
+
)
|
|
196
|
+
allow(Glancer::Workflow::LLM)
|
|
197
|
+
.to receive(:explain_error)
|
|
198
|
+
.and_return("I couldn't process the query after 3 attempts.")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "returns :successful false" do
|
|
202
|
+
result = described_class.run(chat.id, question)
|
|
203
|
+
expect(result[:successful]).to be(false)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "returns the error explanation in :content" do
|
|
207
|
+
result = described_class.run(chat.id, question)
|
|
208
|
+
expect(result[:content]).to include("3 attempts")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# ── Sanitization blocks the pipeline ─────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
describe ".run — SQL sanitizer blocks execution" do
|
|
215
|
+
before do
|
|
216
|
+
Glancer::Workflow::Cache.clear
|
|
217
|
+
|
|
218
|
+
allow(Glancer::Retriever).to receive(:search).and_return([embedding])
|
|
219
|
+
# LLM returns a DROP statement (malicious)
|
|
220
|
+
allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return("DROP TABLE users")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "raises Glancer::Error (via the sanitizer)" do
|
|
224
|
+
expect { described_class.run(chat.id, question) }.to raise_error(Glancer::Error)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# ── chat not found ────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
describe ".run — chat not found" do
|
|
231
|
+
it "raises Glancer::Error wrapping the ActiveRecord error" do
|
|
232
|
+
expect { described_class.run(999_999, question) }.to raise_error(Glancer::Error)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# ── ActiveRecord mode ─────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
describe ".run — activerecord pipeline" do
|
|
239
|
+
let(:ar_code) { "Glancer::Chat.count" }
|
|
240
|
+
|
|
241
|
+
before do
|
|
242
|
+
Glancer::Workflow::Cache.clear
|
|
243
|
+
Glancer.configuration.query_mode = :activerecord
|
|
244
|
+
|
|
245
|
+
allow(Glancer::Retriever).to receive(:search).and_return([embedding])
|
|
246
|
+
allow(Glancer::Workflow::Builder).to receive(:build_ar_code).and_return("```ruby\n#{ar_code}\n```")
|
|
247
|
+
allow(Glancer::Workflow::ARExecutor).to receive(:execute).and_return([{ "result" => 0 }])
|
|
248
|
+
allow(Glancer::Workflow::LLM).to receive(:humanized_response).and_return("Zero chats.")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
after { Glancer.configuration.query_mode = :sql }
|
|
252
|
+
|
|
253
|
+
it "returns :successful true" do
|
|
254
|
+
result = described_class.run(chat.id, question)
|
|
255
|
+
expect(result[:successful]).to be(true)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "returns code_type 'activerecord'" do
|
|
259
|
+
result = described_class.run(chat.id, question)
|
|
260
|
+
expect(result[:code_type]).to eq("activerecord")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "returns the humanized content" do
|
|
264
|
+
result = described_class.run(chat.id, question)
|
|
265
|
+
expect(result[:content]).to eq("Zero chats.")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it "returns :successful false and explanation when AR executor reports an error" do
|
|
269
|
+
allow(Glancer::Workflow::ARExecutor).to receive(:execute).and_return(
|
|
270
|
+
{ error: true, message: "undefined constant", last_code: ar_code }
|
|
271
|
+
)
|
|
272
|
+
allow(Glancer::Workflow::LLM).to receive(:explain_error).and_return("I couldn't fix it.")
|
|
273
|
+
result = described_class.run(chat.id, question)
|
|
274
|
+
expect(result[:successful]).to be(false)
|
|
275
|
+
expect(result[:content]).to eq("I couldn't fix it.")
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# ── enrich_question ───────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe ".run — question enrichment enabled" do
|
|
282
|
+
before do
|
|
283
|
+
Glancer::Workflow::Cache.clear
|
|
284
|
+
Glancer.configuration.query_enrichment_enabled = true
|
|
285
|
+
|
|
286
|
+
allow(Glancer::Retriever).to receive(:search).and_return([embedding])
|
|
287
|
+
allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return("```sql\n#{sql}\n```")
|
|
288
|
+
allow(Glancer::Workflow::SQLValidator).to receive(:validate_tables_exist!)
|
|
289
|
+
allow(Glancer::Workflow::Executor).to receive(:execute).and_return([{ "cnt" => 42 }])
|
|
290
|
+
allow(Glancer::Workflow::LLM).to receive(:humanized_response).and_return("42 users.")
|
|
291
|
+
allow(Glancer::Workflow::QueryEnricher).to receive(:known_table_names).and_return(["users"])
|
|
292
|
+
allow(Glancer::Workflow::QueryEnricher).to receive(:enrich).and_return("enriched: #{question}")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
after { Glancer.configuration.query_enrichment_enabled = false }
|
|
296
|
+
|
|
297
|
+
it "stores enriched_question in the result" do
|
|
298
|
+
result = described_class.run(chat.id, question)
|
|
299
|
+
expect(result[:enriched_question]).to eq("enriched: #{question}")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it "falls back to the original question when enrichment raises" do
|
|
303
|
+
allow(Glancer::Workflow::QueryEnricher).to receive(:enrich).and_raise(StandardError, "LLM timeout")
|
|
304
|
+
result = described_class.run(chat.id, question)
|
|
305
|
+
expect(result[:enriched_question]).to eq(question)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Audit do
|
|
6
|
+
let(:valid_attrs) do
|
|
7
|
+
{
|
|
8
|
+
code: "SELECT 1 /*glancer,run_id:abc*/",
|
|
9
|
+
adapter: "sqlite",
|
|
10
|
+
run_id: SecureRandom.uuid,
|
|
11
|
+
executed_at: Time.current
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
subject(:audit) { described_class.new(valid_attrs) }
|
|
16
|
+
|
|
17
|
+
# ── Validations ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
describe "validations" do
|
|
20
|
+
it "is valid with all required attributes" do
|
|
21
|
+
expect(audit).to be_valid
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "is invalid without code" do
|
|
25
|
+
audit.code = nil
|
|
26
|
+
expect(audit).not_to be_valid
|
|
27
|
+
expect(audit.errors[:code]).to be_present
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "is invalid without adapter" do
|
|
31
|
+
audit.adapter = nil
|
|
32
|
+
expect(audit).not_to be_valid
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "is invalid without run_id" do
|
|
36
|
+
audit.run_id = nil
|
|
37
|
+
expect(audit).not_to be_valid
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "is invalid without executed_at" do
|
|
41
|
+
audit.executed_at = nil
|
|
42
|
+
expect(audit).not_to be_valid
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "enforces uniqueness of run_id" do
|
|
46
|
+
uuid = SecureRandom.uuid
|
|
47
|
+
described_class.create!(valid_attrs.merge(run_id: uuid))
|
|
48
|
+
duplicate = described_class.new(valid_attrs.merge(run_id: uuid))
|
|
49
|
+
expect(duplicate).not_to be_valid
|
|
50
|
+
expect(duplicate.errors[:run_id]).to be_present
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# ── Associations ─────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe "associations" do
|
|
57
|
+
it "belongs_to message optionally" do
|
|
58
|
+
expect(audit).to be_valid # message_id is nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "links to a message when provided" do
|
|
62
|
+
chat = Glancer::Chat.create!(title: "C")
|
|
63
|
+
msg = Glancer::Message.create!(chat: chat, role: "assistant", content: "x")
|
|
64
|
+
audit.message = msg
|
|
65
|
+
audit.save!
|
|
66
|
+
expect(audit.reload.message).to eq(msg)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ── Persistence ───────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe "persistence" do
|
|
73
|
+
it "saves successfully with valid attributes" do
|
|
74
|
+
expect { described_class.create!(valid_attrs) }.to change(described_class, :count).by(1)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "stores the question (optional)" do
|
|
78
|
+
a = described_class.create!(valid_attrs.merge(question: "How many users?"))
|
|
79
|
+
expect(a.reload.question).to eq("How many users?")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Chat do
|
|
6
|
+
subject(:chat) { described_class.new(title: "Test Chat") }
|
|
7
|
+
|
|
8
|
+
# ── Validations ───────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
describe "validations" do
|
|
11
|
+
it "is valid with a title" do
|
|
12
|
+
expect(chat).to be_valid
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "is invalid without a title" do
|
|
16
|
+
chat.title = nil
|
|
17
|
+
expect(chat).not_to be_valid
|
|
18
|
+
expect(chat.errors[:title]).to include("can't be blank")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "is invalid with an empty title string" do
|
|
22
|
+
chat.title = ""
|
|
23
|
+
expect(chat).not_to be_valid
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ── Associations ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe "associations" do
|
|
30
|
+
let!(:saved_chat) { described_class.create!(title: "Chat") }
|
|
31
|
+
|
|
32
|
+
it "has many messages" do
|
|
33
|
+
msg = Glancer::Message.create!(chat: saved_chat, role: "user", content: "Hello")
|
|
34
|
+
expect(saved_chat.messages).to include(msg)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "destroys associated messages when the chat is destroyed" do
|
|
38
|
+
Glancer::Message.create!(chat: saved_chat, role: "user", content: "Hello")
|
|
39
|
+
expect { saved_chat.destroy }.to change(Glancer::Message, :count).by(-1)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ── Persistence ───────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe "persistence" do
|
|
46
|
+
it "can be saved" do
|
|
47
|
+
expect(chat.save).to be(true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "increments the count on create!" do
|
|
51
|
+
expect { described_class.create!(title: "New") }.to change(described_class, :count).by(1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "has timestamps after save" do
|
|
55
|
+
chat.save!
|
|
56
|
+
expect(chat.created_at).not_to be_nil
|
|
57
|
+
expect(chat.updated_at).not_to be_nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::CodeVersion do
|
|
6
|
+
let(:chat) { Glancer::Chat.create!(title: "Chat") }
|
|
7
|
+
let(:message) { Glancer::Message.create!(chat: chat, role: "assistant", content: "result", code: "SELECT 1") }
|
|
8
|
+
|
|
9
|
+
subject(:code_version) { described_class.new(message: message, code: "SELECT 1", source: "generated") }
|
|
10
|
+
|
|
11
|
+
# ── Validations ───────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe "validations" do
|
|
14
|
+
it "is valid with all required attributes" do
|
|
15
|
+
expect(code_version).to be_valid
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "is invalid without code" do
|
|
19
|
+
code_version.code = nil
|
|
20
|
+
expect(code_version).not_to be_valid
|
|
21
|
+
expect(code_version.errors[:code]).to be_present
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "is invalid without source" do
|
|
25
|
+
code_version.source = nil
|
|
26
|
+
expect(code_version).not_to be_valid
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ── Enum: source ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe "source enum" do
|
|
33
|
+
it "accepts 'generated'" do
|
|
34
|
+
sv = described_class.create!(message: message, code: "SELECT 1", source: "generated")
|
|
35
|
+
expect(sv.generated?).to be(true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "accepts 'user_edited'" do
|
|
39
|
+
sv = described_class.create!(message: message, code: "SELECT 2", source: "user_edited")
|
|
40
|
+
expect(sv.user_edited?).to be(true)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "raises on an invalid source" do
|
|
44
|
+
expect { described_class.create!(message: message, code: "SELECT 1", source: "unknown") }
|
|
45
|
+
.to raise_error(ArgumentError)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ── Associations ─────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe "associations" do
|
|
52
|
+
it "belongs_to a message" do
|
|
53
|
+
sv = described_class.create!(message: message, code: "SELECT 1", source: "generated")
|
|
54
|
+
expect(sv.message).to eq(message)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ── Persistence ───────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe "persistence" do
|
|
61
|
+
it "increments count on create!" do
|
|
62
|
+
expect { described_class.create!(message: message, code: "SELECT 1", source: "generated") }
|
|
63
|
+
.to change(described_class, :count).by(1)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "stores the code content" do
|
|
67
|
+
sv = described_class.create!(message: message, code: "SELECT id FROM users", source: "generated")
|
|
68
|
+
expect(sv.reload.code).to eq("SELECT id FROM users")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|