glancer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +96 -0
  3. data/.rubocop.yml +54 -0
  4. data/CHANGELOG.md +88 -0
  5. data/CLAUDE.md +115 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/README.md +354 -0
  8. data/app/assets/config/glancer_manifest.js +1 -0
  9. data/app/assets/javascripts/glancer/application.js +15 -0
  10. data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
  11. data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
  12. data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
  13. data/app/assets/stylesheets/glancer/application.css +350 -0
  14. data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
  15. data/app/assets/stylesheets/glancer/list.css +31 -0
  16. data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
  17. data/app/assets/stylesheets/glancer/table.css +97 -0
  18. data/app/controllers/glancer/application_controller.rb +33 -0
  19. data/app/controllers/glancer/chats_controller.rb +49 -0
  20. data/app/controllers/glancer/messages_controller.rb +144 -0
  21. data/app/controllers/glancer/schema_controller.rb +29 -0
  22. data/app/controllers/glancer/settings_controller.rb +23 -0
  23. data/app/helpers/glancer/application_helper.rb +17 -0
  24. data/app/jobs/glancer/application_job.rb +6 -0
  25. data/app/jobs/glancer/process_message_job.rb +38 -0
  26. data/app/models/glancer/audit.rb +12 -0
  27. data/app/models/glancer/chat.rb +8 -0
  28. data/app/models/glancer/code_version.rb +12 -0
  29. data/app/models/glancer/embedding.rb +6 -0
  30. data/app/models/glancer/message.rb +25 -0
  31. data/app/models/glancer/setting.rb +23 -0
  32. data/app/models/glancer/sql_version.rb +6 -0
  33. data/app/views/glancer/_data/_importmap.json.erb +7 -0
  34. data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
  35. data/app/views/glancer/chats/_show.html.erb +52 -0
  36. data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
  37. data/app/views/glancer/chats/index.html.erb +10 -0
  38. data/app/views/glancer/chats/show.html.erb +1 -0
  39. data/app/views/glancer/messages/_data_table.html.erb +268 -0
  40. data/app/views/glancer/messages/_execution_error.html.erb +26 -0
  41. data/app/views/glancer/messages/_form.html.erb +93 -0
  42. data/app/views/glancer/messages/_message.html.erb +206 -0
  43. data/app/views/glancer/messages/_message_info.html.erb +176 -0
  44. data/app/views/glancer/messages/_temp_form.html.erb +100 -0
  45. data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
  46. data/app/views/glancer/schema/show.html.erb +123 -0
  47. data/app/views/glancer/settings/show.html.erb +306 -0
  48. data/app/views/glancer/shared/_icons.html.erb +126 -0
  49. data/app/views/layouts/glancer/application.html.erb +234 -0
  50. data/config/locales/glancer.en.yml +90 -0
  51. data/config/locales/glancer.es.yml +90 -0
  52. data/config/locales/glancer.pt-BR.yml +90 -0
  53. data/config/routes.rb +20 -0
  54. data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
  55. data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
  56. data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
  57. data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
  58. data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
  59. data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
  60. data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
  61. data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
  62. data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
  63. data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
  64. data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
  65. data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
  66. data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
  67. data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
  68. data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
  69. data/lib/generators/glancer/install/install_generator.rb +74 -0
  70. data/lib/generators/glancer/install/templates/glancer.rb +227 -0
  71. data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
  72. data/lib/glancer/async_runner.rb +50 -0
  73. data/lib/glancer/chart_analyzer.rb +230 -0
  74. data/lib/glancer/configuration.rb +372 -0
  75. data/lib/glancer/engine.rb +90 -0
  76. data/lib/glancer/indexer/context_indexer.rb +58 -0
  77. data/lib/glancer/indexer/model_indexer.rb +64 -0
  78. data/lib/glancer/indexer/schema_indexer.rb +171 -0
  79. data/lib/glancer/indexer.rb +50 -0
  80. data/lib/glancer/retriever.rb +114 -0
  81. data/lib/glancer/utils/logger.rb +83 -0
  82. data/lib/glancer/utils/markdown_helper.rb +56 -0
  83. data/lib/glancer/utils/result_formatter.rb +25 -0
  84. data/lib/glancer/utils/table_stats.rb +18 -0
  85. data/lib/glancer/utils/transaction.rb +59 -0
  86. data/lib/glancer/version.rb +5 -0
  87. data/lib/glancer/workflow/ar_executor.rb +104 -0
  88. data/lib/glancer/workflow/ar_extractor.rb +25 -0
  89. data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
  90. data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
  91. data/lib/glancer/workflow/builder.rb +129 -0
  92. data/lib/glancer/workflow/cache.rb +55 -0
  93. data/lib/glancer/workflow/executor.rb +72 -0
  94. data/lib/glancer/workflow/llm.rb +123 -0
  95. data/lib/glancer/workflow/prompt_builder.rb +143 -0
  96. data/lib/glancer/workflow/query_enricher.rb +117 -0
  97. data/lib/glancer/workflow/sql_extractor.rb +42 -0
  98. data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
  99. data/lib/glancer/workflow/sql_validator.rb +67 -0
  100. data/lib/glancer/workflow.rb +158 -0
  101. data/lib/glancer.rb +50 -0
  102. data/lib/tasks/glancer/tailwind.rake +8 -0
  103. data/lib/tasks/glancer.rake +99 -0
  104. data/spec/glancer_spec.rb +62 -0
  105. data/spec/lib/glancer/async_runner_spec.rb +133 -0
  106. data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
  107. data/spec/lib/glancer/configuration_spec.rb +858 -0
  108. data/spec/lib/glancer/engine_spec.rb +209 -0
  109. data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
  110. data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
  111. data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
  112. data/spec/lib/glancer/indexer_spec.rb +95 -0
  113. data/spec/lib/glancer/retriever_spec.rb +179 -0
  114. data/spec/lib/glancer/utils/logger_spec.rb +85 -0
  115. data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
  116. data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
  117. data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
  118. data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
  119. data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
  120. data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
  121. data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
  122. data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
  123. data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
  124. data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
  125. data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
  126. data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
  127. data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
  128. data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
  129. data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
  130. data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
  131. data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
  132. data/spec/lib/glancer/workflow_spec.rb +308 -0
  133. data/spec/models/glancer/audit_spec.rb +82 -0
  134. data/spec/models/glancer/chat_spec.rb +60 -0
  135. data/spec/models/glancer/code_version_spec.rb +71 -0
  136. data/spec/models/glancer/embedding_spec.rb +73 -0
  137. data/spec/models/glancer/message_spec.rb +144 -0
  138. data/spec/models/glancer/setting_spec.rb +88 -0
  139. data/spec/models/glancer/sql_version_spec.rb +4 -0
  140. data/spec/spec_helper.rb +128 -0
  141. data/spec/support/schema.rb +55 -0
  142. metadata +255 -0
@@ -0,0 +1,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