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,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Glancer::Utils::Logger do
|
|
7
|
+
describe ".with_debug_logs" do
|
|
8
|
+
it "temporarily sets log_verbosity to :debug and restores it" do
|
|
9
|
+
Glancer.configuration.log_verbosity = :none
|
|
10
|
+
described_class.with_debug_logs do
|
|
11
|
+
expect(Glancer.configuration.log_verbosity).to eq(:debug)
|
|
12
|
+
end
|
|
13
|
+
expect(Glancer.configuration.log_verbosity).to eq(:none)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "restores the original verbosity even if the block raises" do
|
|
17
|
+
Glancer.configuration.log_verbosity = :info
|
|
18
|
+
expect do
|
|
19
|
+
described_class.with_debug_logs { raise "oops" }
|
|
20
|
+
end.to raise_error(RuntimeError)
|
|
21
|
+
expect(Glancer.configuration.log_verbosity).to eq(:info)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "yields control to the caller's block" do
|
|
25
|
+
yielded = false
|
|
26
|
+
described_class.with_debug_logs { yielded = true }
|
|
27
|
+
expect(yielded).to be(true)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe "log output to file" do
|
|
32
|
+
let(:log_file) { Tempfile.new(["glancer_test", ".log"]) }
|
|
33
|
+
|
|
34
|
+
after { log_file.unlink }
|
|
35
|
+
|
|
36
|
+
it "writes log lines to the configured output file" do
|
|
37
|
+
Glancer.configuration.log_output_path = log_file.path
|
|
38
|
+
Glancer.configuration.log_verbosity = :info
|
|
39
|
+
|
|
40
|
+
described_class.info("TestTag", "hello from file logger")
|
|
41
|
+
|
|
42
|
+
content = File.read(log_file.path)
|
|
43
|
+
expect(content).to include("hello from file logger")
|
|
44
|
+
ensure
|
|
45
|
+
Glancer.configuration.log_output_path = nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe "verbosity filtering" do
|
|
50
|
+
before do
|
|
51
|
+
Glancer.configuration.log_verbosity = :none
|
|
52
|
+
Glancer.configuration.log_output_path = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "suppresses info messages at :none verbosity" do
|
|
56
|
+
expect { described_class.info("T", "suppressed") }.not_to output.to_stdout
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "always outputs error messages regardless of verbosity" do
|
|
60
|
+
expect { described_class.error("T", "critical error") }.to output(/critical error/).to_stdout
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "always outputs warn messages regardless of verbosity" do
|
|
64
|
+
expect { described_class.warn("T", "a warning") }.to output(/a warning/).to_stdout
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "suppresses everything with :silent verbosity" do
|
|
68
|
+
Glancer.configuration.log_verbosity = :silent
|
|
69
|
+
expect { described_class.error("T", "silenced error") }.not_to output.to_stdout
|
|
70
|
+
expect { described_class.warn("T", "silenced warning") }.not_to output.to_stdout
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe "graceful degradation when configuration raises" do
|
|
75
|
+
it "falls back to :info verbosity when configuration is unavailable" do
|
|
76
|
+
Glancer.configuration.log_verbosity = :info
|
|
77
|
+
# Even with a mocked config that raises on log_verbosity, the logger shouldn't crash
|
|
78
|
+
original = Glancer.configuration
|
|
79
|
+
Glancer.configuration = nil
|
|
80
|
+
expect { described_class.warn("T", "safe call") }.not_to raise_error
|
|
81
|
+
ensure
|
|
82
|
+
Glancer.configuration = original
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Utils::MarkdownHelper do
|
|
6
|
+
describe ".markdown_to_html" do
|
|
7
|
+
it "converts basic markdown headings to HTML" do
|
|
8
|
+
result = described_class.markdown_to_html("# Hello")
|
|
9
|
+
expect(result).to include("<h1>")
|
|
10
|
+
expect(result).to include("Hello")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "converts bold markdown to <strong>" do
|
|
14
|
+
result = described_class.markdown_to_html("**bold text**")
|
|
15
|
+
expect(result).to include("<strong>")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "converts a markdown paragraph" do
|
|
19
|
+
result = described_class.markdown_to_html("simple paragraph")
|
|
20
|
+
expect(result).to include("simple paragraph")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "wraps markdown tables in .table-scroll-wrapper" do
|
|
24
|
+
md = "| col1 | col2 |\n|------|------|\n| a | b |"
|
|
25
|
+
result = described_class.markdown_to_html(md)
|
|
26
|
+
expect(result).to include("table-scroll-wrapper")
|
|
27
|
+
expect(result).to include("table-scroll-inner")
|
|
28
|
+
expect(result).to include("<table")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "wraps multiple tables independently" do
|
|
32
|
+
md = "| a | b |\n|---|---|\n| 1 | 2 |\n\ntext\n\n| c | d |\n|---|---|\n| 3 | 4 |"
|
|
33
|
+
result = described_class.markdown_to_html(md)
|
|
34
|
+
expect(result.scan("table-scroll-wrapper").size).to eq(2)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "returns a String" do
|
|
38
|
+
expect(described_class.markdown_to_html("hello")).to be_a(String)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe ".highlight_mentions" do
|
|
43
|
+
it "leaves content inside fenced code blocks untouched" do
|
|
44
|
+
text = "```\n@users table here\n```"
|
|
45
|
+
result = described_class.highlight_mentions(text)
|
|
46
|
+
expect(result).to include("@users")
|
|
47
|
+
expect(result).not_to include("glancer-mention")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "skips @mention when valid_set is provided and mention is not in it" do
|
|
51
|
+
result = described_class.highlight_mentions("check @unknown table", valid_tables: %w[users orders])
|
|
52
|
+
expect(result).to include("@unknown")
|
|
53
|
+
expect(result).not_to include("glancer-mention")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "wraps a valid @mention in a link without schema_base" do
|
|
57
|
+
result = described_class.highlight_mentions("check @users table", valid_tables: %w[users])
|
|
58
|
+
expect(result).to include('class="glancer-mention"')
|
|
59
|
+
expect(result).to include("@users")
|
|
60
|
+
expect(result).to include('href="#"')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "wraps a valid @mention in a link with schema_base" do
|
|
64
|
+
result = described_class.highlight_mentions("see @orders", schema_base: "/glancer", valid_tables: %w[orders])
|
|
65
|
+
expect(result).to include('href="/glancer?table=orders"')
|
|
66
|
+
expect(result).to include('target="_blank"')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe ".extract_sql_from_markdown" do
|
|
71
|
+
it "extracts SQL from a ```sql...``` fenced block" do
|
|
72
|
+
markdown = "Some text\n```sql\nSELECT 1\n```\nMore text"
|
|
73
|
+
expect(described_class.extract_sql_from_markdown(markdown)).to eq("SELECT 1")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "returns empty string when no sql block is present" do
|
|
77
|
+
expect(described_class.extract_sql_from_markdown("no code here")).to eq("")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "returns empty string for a plain ``` block without sql hint" do
|
|
81
|
+
markdown = "```\nSELECT 1\n```"
|
|
82
|
+
expect(described_class.extract_sql_from_markdown(markdown)).to eq("")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "handles multi-line SQL correctly" do
|
|
86
|
+
markdown = "```sql\nSELECT id,\n name\nFROM users\n```"
|
|
87
|
+
result = described_class.extract_sql_from_markdown(markdown)
|
|
88
|
+
expect(result).to include("SELECT id,")
|
|
89
|
+
expect(result).to include("FROM users")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Utils::ResultFormatter do
|
|
6
|
+
describe ".normalize" do
|
|
7
|
+
context "when rows is empty" do
|
|
8
|
+
it "returns the empty array as-is" do
|
|
9
|
+
expect(described_class.normalize([])).to eq([])
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
context "when all rows have the same keys" do
|
|
14
|
+
let(:rows) do
|
|
15
|
+
[
|
|
16
|
+
{ "id" => 1, "name" => "Alice" },
|
|
17
|
+
{ "id" => 2, "name" => "Bob" },
|
|
18
|
+
{ "id" => 3, "name" => "Carol" }
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "pivots to a hash of key => [values...]" do
|
|
23
|
+
result = described_class.normalize(rows)
|
|
24
|
+
expect(result).to be_a(Hash)
|
|
25
|
+
expect(result["id"]).to eq([1, 2, 3])
|
|
26
|
+
expect(result["name"]).to eq(%w[Alice Bob Carol])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "preserves all original keys" do
|
|
30
|
+
result = described_class.normalize(rows)
|
|
31
|
+
expect(result.keys).to contain_exactly("id", "name")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
context "when rows have different keys" do
|
|
36
|
+
let(:rows) do
|
|
37
|
+
[
|
|
38
|
+
{ "id" => 1, "name" => "Alice" },
|
|
39
|
+
{ "id" => 2, "email" => "bob@example.com" }
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "returns the rows array as-is" do
|
|
44
|
+
result = described_class.normalize(rows)
|
|
45
|
+
expect(result).to eq(rows)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
context "with a single-row result set" do
|
|
50
|
+
let(:rows) { [{ "count" => 42 }] }
|
|
51
|
+
|
|
52
|
+
it "pivots to { 'count' => [42] }" do
|
|
53
|
+
result = described_class.normalize(rows)
|
|
54
|
+
expect(result).to eq({ "count" => [42] })
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
context "with symbol keys" do
|
|
59
|
+
let(:rows) do
|
|
60
|
+
[
|
|
61
|
+
{ id: 1, score: 9.5 },
|
|
62
|
+
{ id: 2, score: 7.3 }
|
|
63
|
+
]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "pivots correctly using symbol keys" do
|
|
67
|
+
result = described_class.normalize(rows)
|
|
68
|
+
expect(result[:id]).to eq([1, 2])
|
|
69
|
+
expect(result[:score]).to eq([9.5, 7.3])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Utils::TableStats do
|
|
6
|
+
describe ".count_for" do
|
|
7
|
+
context "when the table exists and is valid" do
|
|
8
|
+
it "returns the row count" do
|
|
9
|
+
Glancer::Chat.create!(title: "A")
|
|
10
|
+
Glancer::Chat.create!(title: "B")
|
|
11
|
+
expect(described_class.count_for("glancer_chats")).to eq(2)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "returns 0 for an empty table" do
|
|
15
|
+
expect(described_class.count_for("glancer_chats")).to eq(0)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
context "when the table name is invalid" do
|
|
20
|
+
it "returns -1 for a table not in the DB" do
|
|
21
|
+
expect(described_class.count_for("nonexistent_xyz_table")).to eq(-1)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
context "when the DB query fails" do
|
|
26
|
+
it "returns -1 and logs a warning" do
|
|
27
|
+
allow(Glancer::Configuration).to receive(:valid_table_name?).and_return(true)
|
|
28
|
+
allow(ActiveRecord::Base.connection).to receive(:select_value)
|
|
29
|
+
.and_raise(StandardError, "DB down")
|
|
30
|
+
expect(described_class.count_for("glancer_chats")).to eq(-1)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Utils::Transaction do
|
|
6
|
+
describe ".make" do
|
|
7
|
+
it "yields the connection to the block" do
|
|
8
|
+
yielded = nil
|
|
9
|
+
described_class.make { |conn| yielded = conn }
|
|
10
|
+
expect(yielded).not_to be_nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "returns the result of the block" do
|
|
14
|
+
result = described_class.make { |_conn| 42 }
|
|
15
|
+
expect(result).to eq(42)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "re-raises standard errors from the block" do
|
|
19
|
+
expect { described_class.make { raise StandardError, "boom" } }
|
|
20
|
+
.to raise_error(StandardError, "boom")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "logs a warning when connection is still open after transaction" do
|
|
24
|
+
conn = ActiveRecord::Base.connection
|
|
25
|
+
allow(conn).to receive(:transaction_open?).and_return(true)
|
|
26
|
+
|
|
27
|
+
expect(Glancer::Utils::Logger).to receive(:warn).with("Utils::Transaction", /not closed/)
|
|
28
|
+
described_class.make { |_conn| }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe ".read_only_connection" do
|
|
33
|
+
context "when read_only_db is nil" do
|
|
34
|
+
it "returns nil" do
|
|
35
|
+
Glancer.configuration.read_only_db = nil
|
|
36
|
+
expect(described_class.read_only_connection).to be_nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
context "when read_only_db is set" do
|
|
41
|
+
before { Glancer.configuration.read_only_db = "sqlite3::memory:" }
|
|
42
|
+
|
|
43
|
+
after { Glancer.configuration.read_only_db = nil }
|
|
44
|
+
|
|
45
|
+
it "sets @used_read_only to true and returns the connection" do
|
|
46
|
+
fake_pool = double("pool", connection: ActiveRecord::Base.connection)
|
|
47
|
+
allow(ActiveRecord::Base).to receive(:establish_connection).and_return(fake_pool)
|
|
48
|
+
conn = described_class.read_only_connection
|
|
49
|
+
expect(conn).not_to be_nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "raises Glancer::Error when establish_connection fails" do
|
|
53
|
+
allow(ActiveRecord::Base).to receive(:establish_connection).and_raise(StandardError, "connection refused")
|
|
54
|
+
expect { described_class.read_only_connection }
|
|
55
|
+
.to raise_error(Glancer::Error, /Read-only DB connection failed/)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe ".connection_config_name" do
|
|
61
|
+
it "returns the pool db_config name for a real connection" do
|
|
62
|
+
conn = ActiveRecord::Base.connection
|
|
63
|
+
result = described_class.connection_config_name(conn)
|
|
64
|
+
expect(result).to be_a(String)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "returns 'unknown' when pool info is unavailable" do
|
|
68
|
+
fake_conn = double("connection")
|
|
69
|
+
allow(fake_conn).to receive(:pool).and_raise(StandardError, "no pool")
|
|
70
|
+
expect(described_class.connection_config_name(fake_conn)).to eq("unknown")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::ARExecutor do
|
|
6
|
+
# ── normalize ─────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe ".normalize" do
|
|
9
|
+
it "converts an ActiveRecord::Relation to an array of attribute hashes" do
|
|
10
|
+
chat = Glancer::Chat.create!(title: "Test")
|
|
11
|
+
relation = Glancer::Chat.where(id: chat.id)
|
|
12
|
+
result = described_class.normalize(relation)
|
|
13
|
+
expect(result).to be_an(Array)
|
|
14
|
+
expect(result.first).to include("id" => chat.id, "title" => "Test")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "converts an Array of AR objects to attribute hashes" do
|
|
18
|
+
chat = Glancer::Chat.create!(title: "AR")
|
|
19
|
+
result = described_class.normalize([chat])
|
|
20
|
+
expect(result).to be_an(Array)
|
|
21
|
+
expect(result.first).to include("title" => "AR")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "wraps a Numeric in a result hash" do
|
|
25
|
+
result = described_class.normalize(42)
|
|
26
|
+
expect(result).to eq([{ "result" => 42 }])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "wraps a String in a result hash" do
|
|
30
|
+
result = described_class.normalize("hello")
|
|
31
|
+
expect(result).to eq([{ "result" => "hello" }])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "normalizes an all-numeric hash (group/count result) to one row per entry" do
|
|
35
|
+
result = described_class.normalize({ "05/2026" => 6, "04/2026" => 2 })
|
|
36
|
+
expect(result).to contain_exactly(
|
|
37
|
+
{ "key" => "05/2026", "value" => 6 },
|
|
38
|
+
{ "key" => "04/2026", "value" => 2 }
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "keeps a mixed-type hash as a single attribute row" do
|
|
43
|
+
result = described_class.normalize({ "name" => "Alice", "age" => 30 })
|
|
44
|
+
expect(result).to eq([{ "name" => "Alice", "age" => 30 }])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "returns [] for nil" do
|
|
48
|
+
expect(described_class.normalize(nil)).to eq([])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "handles an Array of primitives" do
|
|
52
|
+
result = described_class.normalize(%w[a b])
|
|
53
|
+
expect(result).to eq([{ "value" => "a" }, { "value" => "b" }])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "stringifies keys of hashes inside an array" do
|
|
57
|
+
result = described_class.normalize([{ count: 3 }])
|
|
58
|
+
expect(result.first).to include("count" => 3)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "wraps an unknown object (no attributes) in a result/inspect hash" do
|
|
62
|
+
obj = Object.new
|
|
63
|
+
result = described_class.normalize(obj)
|
|
64
|
+
expect(result).to eq([{ "result" => obj.inspect }])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "returns a single-row attribute hash for an AR object with respond_to?(:attributes)" do
|
|
68
|
+
chat = Glancer::Chat.create!(title: "Single")
|
|
69
|
+
result = described_class.normalize(chat)
|
|
70
|
+
expect(result).to be_an(Array)
|
|
71
|
+
expect(result.first).to include("title" => "Single")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "drops columns where every row has nil value" do
|
|
75
|
+
rows = [{ "name" => "Alice", "phantom" => nil }, { "name" => "Bob", "phantom" => nil }]
|
|
76
|
+
result = described_class.drop_all_nil_columns(rows)
|
|
77
|
+
expect(result.first.keys).not_to include("phantom")
|
|
78
|
+
expect(result.first).to include("name" => "Alice")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "keeps columns where at least one row has a non-nil value" do
|
|
82
|
+
rows = [{ "name" => "Alice", "age" => nil }, { "name" => "Bob", "age" => 30 }]
|
|
83
|
+
result = described_class.drop_all_nil_columns(rows)
|
|
84
|
+
expect(result.first.keys).to include("age")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ── execute ───────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe ".execute" do
|
|
91
|
+
let(:safe_code) { "Glancer::Chat.count" }
|
|
92
|
+
|
|
93
|
+
it "returns an array of result hashes for a successful expression" do
|
|
94
|
+
result = described_class.execute(safe_code)
|
|
95
|
+
expect(result).to be_an(Array)
|
|
96
|
+
expect(result.first).to include("result" => 0)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "creates a Glancer::Audit record after success" do
|
|
100
|
+
expect { described_class.execute(safe_code, original_question: "How many chats?") }
|
|
101
|
+
.to change(Glancer::Audit, :count).by(1)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "stores the Ruby expression in the audit code column" do
|
|
105
|
+
described_class.execute(safe_code)
|
|
106
|
+
expect(Glancer::Audit.last.code).to eq(safe_code)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "stores the run_id in the audit record" do
|
|
110
|
+
described_class.execute(safe_code)
|
|
111
|
+
expect(Glancer::Audit.last.run_id).to be_a(String).and be_present
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "stores original_question in the audit record" do
|
|
115
|
+
described_class.execute(safe_code, original_question: "count chats")
|
|
116
|
+
expect(Glancer::Audit.last.question).to eq("count chats")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "rolls back the transaction — side effects inside eval are reverted" do
|
|
120
|
+
# The AR expression itself is read-only, so this just verifies the mechanism works
|
|
121
|
+
result = described_class.execute("Glancer::Chat.count")
|
|
122
|
+
expect(result).to be_an(Array)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ── retry on failure ──────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe ".execute — retry with Builder.fix_ar_code" do
|
|
129
|
+
let(:bad_code) { "NonExistentModel.all" }
|
|
130
|
+
let(:fixed_code) { "Glancer::Chat.count" }
|
|
131
|
+
|
|
132
|
+
before do
|
|
133
|
+
allow(Glancer::Workflow::Builder).to receive(:fix_ar_code).and_return(fixed_code)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "calls Builder.fix_ar_code on failure and retries" do
|
|
137
|
+
expect(Glancer::Workflow::Builder).to receive(:fix_ar_code).at_least(:once).and_return(fixed_code)
|
|
138
|
+
described_class.execute(bad_code)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "returns the result from the fixed code" do
|
|
142
|
+
result = described_class.execute(bad_code)
|
|
143
|
+
expect(result).to be_an(Array)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "returns an error hash after 3 failed attempts" do
|
|
147
|
+
allow(Glancer::Workflow::Builder).to receive(:fix_ar_code).and_return(bad_code)
|
|
148
|
+
result = described_class.execute(bad_code, attempt: 1)
|
|
149
|
+
expect(result).to be_a(Hash)
|
|
150
|
+
expect(result[:error]).to be(true)
|
|
151
|
+
expect(result[:message]).to be_a(String)
|
|
152
|
+
expect(result[:last_code]).to eq(bad_code)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::ARExtractor do
|
|
6
|
+
describe ".extract" do
|
|
7
|
+
it "extracts code from a ```ruby block" do
|
|
8
|
+
text = "```ruby\nUser.where(active: true).count\n```"
|
|
9
|
+
expect(described_class.extract(text)).to eq("User.where(active: true).count")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "extracts code from a plain ``` block" do
|
|
13
|
+
text = "```\nOrder.joins(:items).count\n```"
|
|
14
|
+
expect(described_class.extract(text)).to eq("Order.joins(:items).count")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "strips surrounding whitespace from the code block content" do
|
|
18
|
+
text = "```ruby\n User.all \n```"
|
|
19
|
+
expect(described_class.extract(text)).to eq("User.all")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "returns the raw text when there is no code block" do
|
|
23
|
+
text = "User.count"
|
|
24
|
+
expect(described_class.extract(text)).to eq("User.count")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "strips the raw text when there is no code block" do
|
|
28
|
+
text = " User.count "
|
|
29
|
+
expect(described_class.extract(text)).to eq("User.count")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "handles multi-line expressions inside a code block" do
|
|
33
|
+
text = "```ruby\nUser\n .where(active: true)\n .order(:name)\n```"
|
|
34
|
+
expect(described_class.extract(text)).to include("User")
|
|
35
|
+
expect(described_class.extract(text)).to include(".where(active: true)")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "raises Glancer::Error if extraction fails unexpectedly" do
|
|
39
|
+
allow(described_class).to receive(:extract).and_call_original
|
|
40
|
+
# normal path; just verify it doesn't raise on valid input
|
|
41
|
+
expect { described_class.extract("User.count") }.not_to raise_error
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "raises Glancer::Error wrapping an unexpected StandardError" do
|
|
45
|
+
allow(Glancer::Utils::Logger).to receive(:info).and_raise(StandardError, "unexpected")
|
|
46
|
+
expect { described_class.extract("User.count") }
|
|
47
|
+
.to raise_error(Glancer::Error, /AR code extraction failed/)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Glancer::Workflow::ARPromptBuilder do
|
|
6
|
+
let(:chat) { Glancer::Chat.create!(title: "Chat") }
|
|
7
|
+
let(:embeddings) { [] }
|
|
8
|
+
let(:question) { "How many active users?" }
|
|
9
|
+
|
|
10
|
+
# ── .custom_instructions_block ────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe ".custom_instructions_block" do
|
|
13
|
+
it "returns an empty string when no custom instructions are set" do
|
|
14
|
+
expect(described_class.custom_instructions_block).to eq("")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "returns the instructions wrapped in a header when set" do
|
|
18
|
+
Glancer::Setting.set("custom_instructions", "Always respond in Portuguese.")
|
|
19
|
+
result = described_class.custom_instructions_block
|
|
20
|
+
expect(result).to include("Always respond in Portuguese.")
|
|
21
|
+
expect(result).to include("CUSTOM RULES")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "returns an empty string when Setting.get raises" do
|
|
25
|
+
allow(Glancer::Setting).to receive(:get).and_raise(StandardError, "db error")
|
|
26
|
+
expect(described_class.custom_instructions_block).to eq("")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ── .call ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe ".call" do
|
|
33
|
+
it "returns a String" do
|
|
34
|
+
result = described_class.call(question, embeddings)
|
|
35
|
+
expect(result).to be_a(String)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "includes the question in the prompt" do
|
|
39
|
+
result = described_class.call(question, embeddings)
|
|
40
|
+
expect(result).to include(question)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "includes the schema context from embeddings" do
|
|
44
|
+
embedding = Glancer::Embedding.new(content: "create_table users ...")
|
|
45
|
+
result = described_class.call(question, [embedding])
|
|
46
|
+
expect(result).to include("create_table users")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "includes user history messages" do
|
|
50
|
+
msg = Glancer::Message.create!(chat: chat, role: :user, content: "prior question")
|
|
51
|
+
result = described_class.call(question, embeddings, history: [msg])
|
|
52
|
+
expect(result).to include("prior question")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "formats assistant messages with code in history" do
|
|
56
|
+
msg = Glancer::Message.create!(
|
|
57
|
+
chat: chat, role: :assistant, content: "There are 5.",
|
|
58
|
+
code: "User.count", code_type: "activerecord"
|
|
59
|
+
)
|
|
60
|
+
result = described_class.call(question, embeddings, history: [msg])
|
|
61
|
+
expect(result).to include("User.count")
|
|
62
|
+
expect(result).to include("There are 5.")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "formats assistant messages without code in history" do
|
|
66
|
+
msg = Glancer::Message.create!(
|
|
67
|
+
chat: chat, role: :assistant, content: "I couldn't process that.", code: nil
|
|
68
|
+
)
|
|
69
|
+
result = described_class.call(question, embeddings, history: [msg])
|
|
70
|
+
expect(result).to include("I couldn't process that.")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "raises Glancer::Error when building fails" do
|
|
74
|
+
allow(Time).to receive(:current).and_raise(StandardError, "time error")
|
|
75
|
+
expect { described_class.call(question, embeddings) }
|
|
76
|
+
.to raise_error(Glancer::Error, /AR prompt construction failed/)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|