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,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::LLM do
|
|
6
|
+
let(:fake_response) { double("Response", content: "The query counts all users in the system.") }
|
|
7
|
+
let(:fake_chat) do
|
|
8
|
+
double("Chat").tap do |c|
|
|
9
|
+
allow(c).to receive(:ask).and_return(fake_response)
|
|
10
|
+
allow(c).to receive(:with_instructions).and_return(c)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
allow(RubyLLM).to receive(:chat).and_return(fake_chat)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# ── .humanized_response ───────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe ".humanized_response" do
|
|
21
|
+
let(:question) { "How many users are there?" }
|
|
22
|
+
let(:sql) { "SELECT COUNT(*) FROM users" }
|
|
23
|
+
let(:data) { [{ "count" => 42 }] }
|
|
24
|
+
|
|
25
|
+
it "returns the LLM response content" do
|
|
26
|
+
result = described_class.humanized_response(question, data, sql)
|
|
27
|
+
expect(result).to eq("The query counts all users in the system.")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "calls RubyLLM.chat with the configured chat provider and model" do
|
|
31
|
+
expect(RubyLLM).to receive(:chat).with(
|
|
32
|
+
hash_including(provider: Glancer.configuration.resolved_chat_provider,
|
|
33
|
+
model: Glancer.configuration.resolved_chat_model)
|
|
34
|
+
).and_return(fake_chat)
|
|
35
|
+
described_class.humanized_response(question, data, sql)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "returns a fallback string when the LLM raises" do
|
|
39
|
+
allow(RubyLLM).to receive(:chat).and_raise(StandardError, "timeout")
|
|
40
|
+
result = described_class.humanized_response(question, data, sql)
|
|
41
|
+
expect(result).to include("failed to generate")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "appends custom instructions when they exist" do
|
|
45
|
+
Glancer::Setting.set("custom_instructions", "Always use bullet points")
|
|
46
|
+
# The system prompt should include the custom instruction;
|
|
47
|
+
# we verify by checking that with_instructions is called or ask receives the prompt
|
|
48
|
+
expect(fake_chat).to receive(:ask).and_return(fake_response)
|
|
49
|
+
described_class.humanized_response(question, data, sql)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ── .explain_missing_tables ───────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe ".explain_missing_tables" do
|
|
56
|
+
let(:question) { "Show all affiliates" }
|
|
57
|
+
let(:error_message) { "Table validation failed: Missing table(s) in indexed schema: affiliates" }
|
|
58
|
+
|
|
59
|
+
it "returns the LLM's explanation" do
|
|
60
|
+
allow(fake_response).to receive(:content).and_return("The table 'affiliates' was not found.")
|
|
61
|
+
allow(fake_chat).to receive(:ask).and_return(fake_response)
|
|
62
|
+
result = described_class.explain_missing_tables(question, error_message)
|
|
63
|
+
expect(result).to eq("The table 'affiliates' was not found.")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "returns a fallback string when the LLM raises" do
|
|
67
|
+
allow(RubyLLM).to receive(:chat).and_raise(StandardError, "API error")
|
|
68
|
+
result = described_class.explain_missing_tables(question, error_message)
|
|
69
|
+
expect(result).to be_a(String)
|
|
70
|
+
expect(result).not_to be_empty
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# ── .generate_title ───────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe ".generate_title" do
|
|
77
|
+
let(:question) { "Show me all revenue by region for last quarter" }
|
|
78
|
+
|
|
79
|
+
it "returns the LLM-generated title (truncated to 50 chars)" do
|
|
80
|
+
allow(fake_response).to receive(:content).and_return("Revenue by Region Last Quarter")
|
|
81
|
+
allow(fake_chat).to receive(:ask).and_return(fake_response)
|
|
82
|
+
result = described_class.generate_title(question)
|
|
83
|
+
expect(result).to eq("Revenue by Region Last Quarter")
|
|
84
|
+
expect(result.length).to be <= 50
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "truncates a very long LLM response to 50 characters" do
|
|
88
|
+
long_title = "A" * 100
|
|
89
|
+
allow(fake_response).to receive(:content).and_return(long_title)
|
|
90
|
+
allow(fake_chat).to receive(:ask).and_return(fake_response)
|
|
91
|
+
result = described_class.generate_title(question)
|
|
92
|
+
expect(result.length).to be <= 50
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "falls back to a truncated question when the LLM raises" do
|
|
96
|
+
allow(RubyLLM).to receive(:chat).and_raise(StandardError, "error")
|
|
97
|
+
result = described_class.generate_title(question)
|
|
98
|
+
expect(result).to be_a(String)
|
|
99
|
+
expect(result.length).to be <= 50
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ── .explain_error ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe ".explain_error" do
|
|
106
|
+
let(:question) { "Count orders by customer" }
|
|
107
|
+
let(:error_message) { "no such column: customer_id" }
|
|
108
|
+
let(:sql) { "SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id" }
|
|
109
|
+
|
|
110
|
+
it "returns the LLM's error explanation" do
|
|
111
|
+
allow(fake_response).to receive(:content).and_return("We couldn't find the column customer_id.")
|
|
112
|
+
allow(fake_chat).to receive(:ask).and_return(fake_response)
|
|
113
|
+
result = described_class.explain_error(question, error_message, sql)
|
|
114
|
+
expect(result).to eq("We couldn't find the column customer_id.")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "calls RubyLLM.chat with the chat provider config" do
|
|
118
|
+
expect(RubyLLM).to receive(:chat).with(
|
|
119
|
+
hash_including(provider: Glancer.configuration.resolved_chat_provider)
|
|
120
|
+
).and_return(fake_chat)
|
|
121
|
+
described_class.explain_error(question, error_message, sql)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::PromptBuilder do
|
|
6
|
+
let(:question) { "How many users signed up this month?" }
|
|
7
|
+
let(:embedding1) do
|
|
8
|
+
Glancer::Embedding.create!(
|
|
9
|
+
content: "create_table users ...",
|
|
10
|
+
embedding: [],
|
|
11
|
+
source_type: "schema",
|
|
12
|
+
source_path: "db/schema.rb#users"
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
let(:embedding2) do
|
|
16
|
+
Glancer::Embedding.create!(
|
|
17
|
+
content: "# Foreign Key Relationships\norders.user_id → users.id",
|
|
18
|
+
embedding: [],
|
|
19
|
+
source_type: "schema",
|
|
20
|
+
source_path: "db/schema.rb#foreign_keys"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
let(:embeddings) { [embedding1, embedding2] }
|
|
24
|
+
|
|
25
|
+
let(:chat) { Glancer::Chat.create!(title: "Test") }
|
|
26
|
+
let(:history) { [] }
|
|
27
|
+
|
|
28
|
+
# Mock DB connection's current_database method
|
|
29
|
+
before do
|
|
30
|
+
allow(ActiveRecord::Base.connection).to receive(:current_database).and_return("test_db")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# ── .call ─────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe ".call" do
|
|
36
|
+
subject(:prompt) { described_class.call(question, embeddings, history: history) }
|
|
37
|
+
|
|
38
|
+
it "returns a String" do
|
|
39
|
+
expect(prompt).to be_a(String)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "includes the question" do
|
|
43
|
+
expect(prompt).to include(question)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "includes the adapter type" do
|
|
47
|
+
expect(prompt).to include("SQLITE")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "includes schema content from embeddings" do
|
|
51
|
+
expect(prompt).to include("create_table users")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "places foreign key content in the SCHEMA RELATIONSHIPS section" do
|
|
55
|
+
expect(prompt).to include("orders.user_id → users.id")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "includes OUTPUT SQL ONLY directive" do
|
|
59
|
+
expect(prompt).to include("OUTPUT SQL ONLY")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
context "when history contains messages" do
|
|
63
|
+
let(:user_msg) do
|
|
64
|
+
Glancer::Message.create!(chat: chat, role: "user", content: "Show orders")
|
|
65
|
+
end
|
|
66
|
+
let(:assistant_msg) do
|
|
67
|
+
Glancer::Message.create!(
|
|
68
|
+
chat: chat, role: "assistant", content: "Here are the orders",
|
|
69
|
+
code: "SELECT * FROM orders"
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
let(:history) { [user_msg, assistant_msg] }
|
|
73
|
+
|
|
74
|
+
it "includes the user message in the conversation history" do
|
|
75
|
+
expect(prompt).to include("Show orders")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "includes the assistant SQL in the conversation history" do
|
|
79
|
+
expect(prompt).to include("SELECT * FROM orders")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
context "when history is empty" do
|
|
84
|
+
it "includes a placeholder for no prior messages" do
|
|
85
|
+
expect(prompt).to include("no prior messages")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
context "when custom instructions are set" do
|
|
90
|
+
before { Glancer::Setting.set("custom_instructions", "Always use LIMIT 100") }
|
|
91
|
+
|
|
92
|
+
it "includes the custom instructions" do
|
|
93
|
+
expect(prompt).to include("Always use LIMIT 100")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "raises Glancer::Error on unexpected failure" do
|
|
98
|
+
allow(described_class).to receive(:format_embeddings_with_stats).and_raise(RuntimeError, "boom")
|
|
99
|
+
expect { described_class.call(question, embeddings) }.to raise_error(Glancer::Error, /Prompt construction failed/)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ── .custom_instructions_block ────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe ".custom_instructions_block" do
|
|
106
|
+
it "returns empty string when Glancer::Setting.get raises" do
|
|
107
|
+
allow(Glancer::Setting).to receive(:get).and_raise(StandardError, "DB error")
|
|
108
|
+
expect(described_class.custom_instructions_block).to eq("")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "returns an empty string when no custom instructions are set" do
|
|
112
|
+
expect(described_class.custom_instructions_block).to eq("")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "returns the custom instructions block when set" do
|
|
116
|
+
Glancer::Setting.set("custom_instructions", "Use snake_case")
|
|
117
|
+
result = described_class.custom_instructions_block
|
|
118
|
+
expect(result).to include("Use snake_case")
|
|
119
|
+
expect(result).to include("CUSTOM RULES")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ── .partition_embeddings ─────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe ".partition_embeddings" do
|
|
126
|
+
it "separates FK embeddings from regular embeddings" do
|
|
127
|
+
schema_embeds, fk_text = described_class.partition_embeddings(embeddings)
|
|
128
|
+
expect(schema_embeds).to include(embedding1)
|
|
129
|
+
expect(schema_embeds).not_to include(embedding2)
|
|
130
|
+
expect(fk_text).to include("Foreign Key Relationships")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "returns empty FK text when no FK embeddings exist" do
|
|
134
|
+
_schema_embeds, fk_text = described_class.partition_embeddings([embedding1])
|
|
135
|
+
expect(fk_text).to eq("")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# ── .format_embeddings_with_stats ─────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe ".format_embeddings_with_stats" do
|
|
142
|
+
it "joins the content of each embedding with double newlines" do
|
|
143
|
+
result = described_class.format_embeddings_with_stats([embedding1])
|
|
144
|
+
expect(result).to include("create_table users")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# ── .format_few_shot_examples ─────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe ".format_few_shot_examples" do
|
|
151
|
+
it "returns empty string for empty examples" do
|
|
152
|
+
expect(described_class.format_few_shot_examples([])).to eq("")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "formats examples with question and SQL" do
|
|
156
|
+
examples = [["Count users", "SELECT COUNT(*) FROM users"]]
|
|
157
|
+
result = described_class.format_few_shot_examples(examples)
|
|
158
|
+
expect(result).to include("Count users")
|
|
159
|
+
expect(result).to include("SELECT COUNT(*) FROM users")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "numbers examples starting from 1" do
|
|
163
|
+
examples = [
|
|
164
|
+
["Q1", "SELECT 1"],
|
|
165
|
+
["Q2", "SELECT 2"]
|
|
166
|
+
]
|
|
167
|
+
result = described_class.format_few_shot_examples(examples)
|
|
168
|
+
expect(result).to include("Example 1:")
|
|
169
|
+
expect(result).to include("Example 2:")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ── .example_sql ─────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe ".example_sql" do
|
|
176
|
+
it "returns MySQL example for mysql adapter" do
|
|
177
|
+
result = described_class.example_sql("mysql")
|
|
178
|
+
expect(result).to include("DATE_FORMAT")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "returns MySQL example for mysql2 adapter" do
|
|
182
|
+
result = described_class.example_sql("mysql2")
|
|
183
|
+
expect(result).to include("DATE_FORMAT")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "returns PostgreSQL example for postgres adapter" do
|
|
187
|
+
result = described_class.example_sql("postgres")
|
|
188
|
+
expect(result).to include("TO_CHAR")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "returns a fallback comment for unsupported adapters" do
|
|
192
|
+
result = described_class.example_sql("sqlite")
|
|
193
|
+
expect(result).to include("Example not available")
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::QueryEnricher do
|
|
6
|
+
let(:fake_response) { double("Response", content: "Show all records from users table") }
|
|
7
|
+
let(:fake_chat) do
|
|
8
|
+
double("Chat").tap { |c| allow(c).to receive(:ask).and_return(fake_response) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
before do
|
|
12
|
+
allow(RubyLLM).to receive(:chat).and_return(fake_chat)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# ── .enrich ───────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe ".enrich" do
|
|
18
|
+
let(:question) { "Mostre todas as vendas" }
|
|
19
|
+
let(:table_names) { %w[orders users products] }
|
|
20
|
+
|
|
21
|
+
it "returns the LLM-enriched question" do
|
|
22
|
+
result = described_class.enrich(question, table_names)
|
|
23
|
+
expect(result).to eq("Show all records from users table")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "passes provider and model from configuration" do
|
|
27
|
+
expect(RubyLLM).to receive(:chat).with(
|
|
28
|
+
hash_including(
|
|
29
|
+
provider: Glancer.configuration.resolved_enrichment_provider,
|
|
30
|
+
model: Glancer.configuration.resolved_enrichment_model
|
|
31
|
+
)
|
|
32
|
+
).and_return(fake_chat)
|
|
33
|
+
described_class.enrich(question, table_names)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "returns the original question when table_names is empty" do
|
|
37
|
+
expect(RubyLLM).not_to receive(:chat)
|
|
38
|
+
result = described_class.enrich(question, [])
|
|
39
|
+
expect(result).to eq(question)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "returns the original question when the LLM call fails" do
|
|
43
|
+
allow(RubyLLM).to receive(:chat).and_raise(StandardError, "network error")
|
|
44
|
+
result = described_class.enrich(question, table_names)
|
|
45
|
+
expect(result).to eq(question)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "returns the original question when LLM returns blank content" do
|
|
49
|
+
allow(fake_chat).to receive(:ask).and_return(double("Response", content: " "))
|
|
50
|
+
result = described_class.enrich(question, table_names)
|
|
51
|
+
expect(result).to eq(question)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "includes the table names in the prompt sent to the LLM" do
|
|
55
|
+
expect(fake_chat).to receive(:ask) do |prompt|
|
|
56
|
+
expect(prompt).to include("orders")
|
|
57
|
+
expect(prompt).to include("users")
|
|
58
|
+
fake_response
|
|
59
|
+
end
|
|
60
|
+
described_class.enrich(question, table_names)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "strips whitespace from the enriched question" do
|
|
64
|
+
allow(fake_chat).to receive(:ask).and_return(double("Response", content: " enriched question "))
|
|
65
|
+
result = described_class.enrich(question, table_names)
|
|
66
|
+
expect(result).to eq("enriched question")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "includes assistant messages with code in the history block sent to the LLM" do
|
|
70
|
+
chat_obj = Glancer::Chat.create!(title: "T")
|
|
71
|
+
msg = Glancer::Message.create!(
|
|
72
|
+
chat: chat_obj, role: :assistant, content: "Found 3.",
|
|
73
|
+
code: "User.count", code_type: "activerecord"
|
|
74
|
+
)
|
|
75
|
+
expect(fake_chat).to receive(:ask) do |prompt|
|
|
76
|
+
expect(prompt).to include("ASSISTANT")
|
|
77
|
+
expect(prompt).to include("User.count")
|
|
78
|
+
fake_response
|
|
79
|
+
end
|
|
80
|
+
described_class.enrich(question, table_names, history: [msg])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "includes non-code messages in the history block without code format" do
|
|
84
|
+
chat_obj = Glancer::Chat.create!(title: "T")
|
|
85
|
+
msg = Glancer::Message.create!(chat: chat_obj, role: :user, content: "What is the count?")
|
|
86
|
+
expect(fake_chat).to receive(:ask) do |prompt|
|
|
87
|
+
expect(prompt).to include("USER: What is the count?")
|
|
88
|
+
fake_response
|
|
89
|
+
end
|
|
90
|
+
described_class.enrich(question, table_names, history: [msg])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "includes referenced schema context in prompt when question has @mentioned tables" do
|
|
94
|
+
Glancer::Embedding.create!(
|
|
95
|
+
content: "create_table orders ...", embedding: [],
|
|
96
|
+
source_type: "schema", source_path: "/db/schema.rb#orders"
|
|
97
|
+
)
|
|
98
|
+
expect(fake_chat).to receive(:ask) do |prompt|
|
|
99
|
+
expect(prompt).to include("Referenced Schema")
|
|
100
|
+
fake_response
|
|
101
|
+
end
|
|
102
|
+
described_class.enrich("How many @orders?", ["orders"])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "continues normally when schema context lookup raises (rescue path)" do
|
|
106
|
+
allow(Glancer::Embedding).to receive(:where).and_raise(StandardError, "db error")
|
|
107
|
+
result = described_class.enrich("count @orders", ["orders"])
|
|
108
|
+
expect(result).to be_a(String)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "omits referenced schema block when @mention does not match any table" do
|
|
112
|
+
expect(fake_chat).to receive(:ask) do |prompt|
|
|
113
|
+
expect(prompt).not_to include("Referenced Schema")
|
|
114
|
+
fake_response
|
|
115
|
+
end
|
|
116
|
+
described_class.enrich("How many @ghost records?", table_names)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ── .known_table_names ────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe ".known_table_names" do
|
|
123
|
+
it "returns an empty array when no embeddings exist" do
|
|
124
|
+
expect(described_class.known_table_names).to eq([])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "extracts table names from schema embedding source_paths" do
|
|
128
|
+
Glancer::Embedding.create!(
|
|
129
|
+
content: "create_table users",
|
|
130
|
+
embedding: [],
|
|
131
|
+
source_type: "schema",
|
|
132
|
+
source_path: "/fake/root/db/schema.rb#users"
|
|
133
|
+
)
|
|
134
|
+
expect(described_class.known_table_names).to include("users")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "excludes the foreign_keys pseudo-table" do
|
|
138
|
+
Glancer::Embedding.create!(
|
|
139
|
+
content: "fk block",
|
|
140
|
+
embedding: [],
|
|
141
|
+
source_type: "schema",
|
|
142
|
+
source_path: "/fake/root/db/schema.rb#foreign_keys"
|
|
143
|
+
)
|
|
144
|
+
expect(described_class.known_table_names).not_to include("foreign_keys")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "excludes non-schema embeddings" do
|
|
148
|
+
Glancer::Embedding.create!(
|
|
149
|
+
content: "model content",
|
|
150
|
+
embedding: [],
|
|
151
|
+
source_type: "model",
|
|
152
|
+
source_path: "app/models/user.rb"
|
|
153
|
+
)
|
|
154
|
+
expect(described_class.known_table_names).to be_empty
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "excludes inflections chunks (paths that contain /)" do
|
|
158
|
+
Glancer::Embedding.create!(
|
|
159
|
+
content: "inflections",
|
|
160
|
+
embedding: [],
|
|
161
|
+
source_type: "schema",
|
|
162
|
+
source_path: "config/initializers/inflections.rb"
|
|
163
|
+
)
|
|
164
|
+
expect(described_class.known_table_names).to be_empty
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "deduplicates table names" do
|
|
168
|
+
2.times do
|
|
169
|
+
Glancer::Embedding.create!(
|
|
170
|
+
content: "table",
|
|
171
|
+
embedding: [],
|
|
172
|
+
source_type: "schema",
|
|
173
|
+
source_path: "/fake/db/schema.rb#orders"
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
expect(described_class.known_table_names.count("orders")).to eq(1)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "returns [] when the DB query raises" do
|
|
180
|
+
allow(Glancer::Embedding).to receive(:where).and_raise(StandardError, "db error")
|
|
181
|
+
expect(described_class.known_table_names).to eq([])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::SQLExtractor do
|
|
6
|
+
describe ".extract" do
|
|
7
|
+
context "when the text contains a ```sql ... ``` code block" do
|
|
8
|
+
it "extracts the SQL from a fenced sql block" do
|
|
9
|
+
text = "Here is the query:\n```sql\nSELECT * FROM users\n```"
|
|
10
|
+
expect(described_class.extract(text)).to eq("SELECT * FROM users")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "handles multi-line SQL inside the block" do
|
|
14
|
+
text = "```sql\nSELECT id, name\nFROM users\nWHERE active = 1\n```"
|
|
15
|
+
result = described_class.extract(text)
|
|
16
|
+
expect(result).to include("SELECT id, name")
|
|
17
|
+
expect(result).to include("FROM users")
|
|
18
|
+
expect(result).to include("WHERE active = 1")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "handles SQL blocks with uppercase SQL language hint" do
|
|
22
|
+
text = "```SQL\nSELECT 1\n```"
|
|
23
|
+
expect(described_class.extract(text)).to eq("SELECT 1")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "strips surrounding whitespace from the extracted SQL" do
|
|
27
|
+
text = "```sql\n SELECT 1 \n```"
|
|
28
|
+
expect(described_class.extract(text)).to eq("SELECT 1")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
context "when the text contains a plain ``` ... ``` block without a language hint" do
|
|
33
|
+
it "extracts the SQL from a plain fenced block" do
|
|
34
|
+
text = "Result:\n```\nSELECT 1\n```"
|
|
35
|
+
expect(described_class.extract(text)).to eq("SELECT 1")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
context "when there is no code block but a SELECT keyword exists" do
|
|
40
|
+
it "finds the SELECT line and takes everything from there" do
|
|
41
|
+
text = "Sure, I can help.\nSELECT * FROM orders\nWHERE id = 1"
|
|
42
|
+
result = described_class.extract(text)
|
|
43
|
+
expect(result).to start_with("SELECT * FROM orders")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "detects a WITH (CTE) keyword as SQL start" do
|
|
47
|
+
text = "Here's a CTE:\nWITH cte AS (SELECT 1) SELECT * FROM cte"
|
|
48
|
+
result = described_class.extract(text)
|
|
49
|
+
expect(result).to start_with("WITH cte AS")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "detects EXPLAIN keyword as SQL start" do
|
|
53
|
+
text = "Explanation:\nEXPLAIN SELECT * FROM users"
|
|
54
|
+
result = described_class.extract(text)
|
|
55
|
+
expect(result).to start_with("EXPLAIN SELECT")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "is case-insensitive for the SQL start pattern" do
|
|
59
|
+
text = "some text\nselect * from t"
|
|
60
|
+
result = described_class.extract(text)
|
|
61
|
+
expect(result).to include("select * from t")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
context "when no SQL can be found" do
|
|
66
|
+
it "returns the raw text joined into a single line (raw join fallback)" do
|
|
67
|
+
text = "I cannot generate a query for this request."
|
|
68
|
+
result = described_class.extract(text)
|
|
69
|
+
expect(result).to include("I cannot generate")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
context "error handling" do
|
|
74
|
+
it "raises Glancer::Error when an unexpected exception occurs" do
|
|
75
|
+
allow(described_class).to receive(:extract).and_call_original
|
|
76
|
+
bad_text = double("bad text")
|
|
77
|
+
allow(bad_text).to receive(:=~).and_raise(RuntimeError, "unexpected")
|
|
78
|
+
expect { described_class.extract(bad_text) }.to raise_error(Glancer::Error, /SQL extraction failed/)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::SQLSanitizer do
|
|
6
|
+
describe ".ensure_safe!" do
|
|
7
|
+
context "with safe SELECT queries" do
|
|
8
|
+
it "passes a simple SELECT without raising" do
|
|
9
|
+
expect { described_class.ensure_safe!("SELECT * FROM users") }.not_to raise_error
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "passes a SELECT with a JOIN" do
|
|
13
|
+
sql = "SELECT u.id, o.total FROM users u JOIN orders o ON u.id = o.user_id"
|
|
14
|
+
expect { described_class.ensure_safe!(sql) }.not_to raise_error
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "passes a WITH (CTE) query" do
|
|
18
|
+
sql = "WITH cte AS (SELECT id FROM users) SELECT * FROM cte"
|
|
19
|
+
expect { described_class.ensure_safe!(sql) }.not_to raise_error
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
context "with forbidden keywords" do
|
|
24
|
+
%w[DELETE UPDATE INSERT DROP TRUNCATE ALTER CREATE REPLACE].each do |keyword|
|
|
25
|
+
it "raises Glancer::Error when SQL contains #{keyword}" do
|
|
26
|
+
sql = "#{keyword} FROM users"
|
|
27
|
+
expect { described_class.ensure_safe!(sql) }.to raise_error(Glancer::Error, /#{keyword.downcase}/i)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "raises Glancer::Error regardless of case for #{keyword}" do
|
|
31
|
+
sql = "#{keyword.downcase} FROM users"
|
|
32
|
+
expect { described_class.ensure_safe!(sql) }.to raise_error(Glancer::Error)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
context "when a forbidden keyword appears inside a string literal" do
|
|
38
|
+
it "does NOT raise for DELETE inside a string value" do
|
|
39
|
+
sql = "SELECT * FROM users WHERE action = 'delete this record'"
|
|
40
|
+
expect { described_class.ensure_safe!(sql) }.not_to raise_error
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "does NOT raise for UPDATE inside a string value" do
|
|
44
|
+
sql = "SELECT notes FROM logs WHERE notes = 'update pending'"
|
|
45
|
+
expect { described_class.ensure_safe!(sql) }.not_to raise_error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "does NOT raise for DROP inside a string value" do
|
|
49
|
+
sql = "SELECT * FROM archive WHERE label = 'drop unused'"
|
|
50
|
+
expect { described_class.ensure_safe!(sql) }.not_to raise_error
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context "when a forbidden keyword appears inside an inline comment" do
|
|
55
|
+
it "does NOT raise for DELETE inside a -- comment" do
|
|
56
|
+
sql = "SELECT * FROM users -- delete this comment is harmless"
|
|
57
|
+
expect { described_class.ensure_safe!(sql) }.not_to raise_error
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "does NOT raise for DROP inside a /* */ block comment" do
|
|
61
|
+
sql = "SELECT 1 /* DROP TABLE users */"
|
|
62
|
+
expect { described_class.ensure_safe!(sql) }.not_to raise_error
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context "with mixed-case forbidden keywords" do
|
|
67
|
+
it "raises for 'Delete' (title case)" do
|
|
68
|
+
expect { described_class.ensure_safe!("Delete FROM users") }.to raise_error(Glancer::Error)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "raises for 'dRoP'" do
|
|
72
|
+
expect { described_class.ensure_safe!("dRoP TABLE users") }.to raise_error(Glancer::Error)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe ".strip_strings_and_comments" do
|
|
78
|
+
it "removes single-quoted string literals" do
|
|
79
|
+
result = described_class.strip_strings_and_comments("select 'hello world' from t")
|
|
80
|
+
expect(result).not_to include("hello world")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "removes inline -- comments" do
|
|
84
|
+
result = described_class.strip_strings_and_comments("select 1 -- this is a comment\nfrom t")
|
|
85
|
+
expect(result).not_to include("this is a comment")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "removes block /* */ comments" do
|
|
89
|
+
result = described_class.strip_strings_and_comments("select /* drop tables */ 1")
|
|
90
|
+
expect(result).not_to include("drop tables")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "preserves the SQL structure outside of stripped sections" do
|
|
94
|
+
result = described_class.strip_strings_and_comments("select id from users")
|
|
95
|
+
expect(result).to include("select id from users")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|