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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ module Workflow
5
+ def self.run(chat_id, question, cache: true, &status_callback)
6
+ Glancer::Utils::Logger.info("Workflow",
7
+ "Running workflow for chat_id: #{chat_id.inspect}, " \
8
+ "question: #{question.inspect}, " \
9
+ "mode: #{Glancer.configuration.query_mode}, cache: #{cache}")
10
+
11
+ if cache && (cached = Workflow::Cache.fetch(question))
12
+ Glancer::Utils::Logger.info("Workflow", "Using cached result for question: #{question.inspect}")
13
+ return cached.merge(from_cache: true)
14
+ end
15
+
16
+ chat = Glancer::Chat.find(chat_id)
17
+ history = chat.messages.order(created_at: :desc).limit(Glancer.configuration.history_limit).reverse
18
+
19
+ enrichment_enabled = Glancer.configuration.query_enrichment_enabled
20
+ adapter = Glancer.configuration.query_mode
21
+
22
+ status_callback&.call(:enriching) if enrichment_enabled
23
+ effective_question = enrich_question(question, history, adapter: adapter)
24
+
25
+ status_callback&.call(:retrieving_context)
26
+ embeddings = Retriever.search(effective_question)
27
+ Glancer::Utils::Logger.debug("Workflow", "Retrieved #{embeddings.size} relevant document(s) for context")
28
+
29
+ result = if adapter == :activerecord
30
+ run_activerecord(question, effective_question, embeddings, history, status_callback)
31
+ else
32
+ run_sql(question, effective_question, embeddings, history, status_callback)
33
+ end
34
+
35
+ # Always persist the enriched question so the info panel can show it on every message
36
+ result[:enriched_question] = effective_question if enrichment_enabled
37
+
38
+ if cache && result[:successful]
39
+ Workflow::Cache.write(question, result)
40
+ Glancer::Utils::Logger.info("Workflow", "Result cached for question: #{question.inspect}")
41
+ end
42
+
43
+ result
44
+ rescue StandardError => e
45
+ Glancer::Utils::Logger.error("Workflow", "Workflow execution failed: #{e.class} - #{e.message}")
46
+ Glancer::Utils::Logger.debug("Workflow", "Backtrace:\n#{e.backtrace.join("\n")}")
47
+ raise Glancer::Error, "Workflow failed: #{e.message}"
48
+ end
49
+
50
+ def self.enrich_question(question, history = [], adapter: nil)
51
+ return question unless Glancer.configuration.query_enrichment_enabled
52
+
53
+ Glancer::Utils::Logger.info("Workflow", "Enriching question before retrieval...")
54
+ table_names = Glancer::Workflow::QueryEnricher.known_table_names
55
+ enriched = Glancer::Workflow::QueryEnricher.enrich(question, table_names, history: history,
56
+ adapter: adapter)
57
+ Glancer::Utils::Logger.info("Workflow", "Enriched question: #{enriched.inspect}")
58
+ enriched
59
+ rescue StandardError => e
60
+ Glancer::Utils::Logger.warn("Workflow", "Question enrichment failed, using original: #{e.message}")
61
+ question
62
+ end
63
+ private_class_method :enrich_question
64
+
65
+ def self.run_sql(question, effective_question, embeddings, history, status_callback = nil)
66
+ Glancer::Utils::Logger.info("Workflow", "Running SQL code generation mode...")
67
+
68
+ status_callback&.call(:generating_code)
69
+ sql = Workflow::Builder.build_sql(effective_question, embeddings, history: history)
70
+ Glancer::Utils::Logger.debug("Workflow", "Generated raw SQL:\n#{sql}")
71
+
72
+ sql = Workflow::SQLExtractor.extract(sql)
73
+ Glancer::Utils::Logger.debug("Workflow", "Extracted SQL:\n#{sql}")
74
+
75
+ Workflow::SQLSanitizer.ensure_safe!(sql)
76
+
77
+ begin
78
+ status_callback&.call(:validating)
79
+ Workflow::SQLValidator.validate_tables_exist!(sql)
80
+ rescue Glancer::Error => e
81
+ Glancer::Utils::Logger.warn("Workflow", "Table validation failed: #{e.message}. Returning friendly response.")
82
+ explanation = Glancer::Workflow::LLM.explain_missing_tables(question, e.message)
83
+ return {
84
+ question: question,
85
+ content: explanation,
86
+ code: sql,
87
+ code_type: "sql",
88
+ successful: false
89
+ }
90
+ end
91
+
92
+ status_callback&.call(:executing)
93
+ raw_data = Workflow::Executor.execute(sql, original_question: question)
94
+
95
+ if raw_data.is_a?(Hash) && raw_data[:error]
96
+ explanation = Glancer::Workflow::LLM.explain_error(question, raw_data[:message], raw_data[:last_code])
97
+ return {
98
+ question: question,
99
+ content: explanation,
100
+ code: raw_data[:last_code],
101
+ code_type: "sql",
102
+ successful: false
103
+ }
104
+ end
105
+
106
+ status_callback&.call(:humanizing)
107
+ {
108
+ question: question,
109
+ content: Glancer::Workflow::LLM.humanized_response(question, raw_data, sql),
110
+ code: sql,
111
+ code_type: "sql",
112
+ successful: true,
113
+ sources: embeddings.map { |e| { id: e.id, type: e.source_type, path: e.source_path, score: e.score } }
114
+ }
115
+ end
116
+ private_class_method :run_sql
117
+
118
+ def self.run_activerecord(question, effective_question, embeddings, history, status_callback = nil)
119
+ Glancer::Utils::Logger.info("Workflow", "Running ActiveRecord mode...")
120
+
121
+ status_callback&.call(:generating_code)
122
+ code = Workflow::Builder.build_ar_code(effective_question, embeddings, history: history)
123
+ Glancer::Utils::Logger.debug("Workflow", "Generated raw AR code:\n#{code}")
124
+
125
+ code = Workflow::ARExtractor.extract(code)
126
+ Glancer::Utils::Logger.debug("Workflow", "Extracted AR code:\n#{code}")
127
+
128
+ Workflow::ARSanitizer.ensure_safe!(code)
129
+
130
+ status_callback&.call(:executing)
131
+ raw_data = Workflow::ARExecutor.execute(code, original_question: question)
132
+
133
+ if raw_data.is_a?(Hash) && raw_data[:error]
134
+ explanation = Glancer::Workflow::LLM.explain_error(
135
+ question, raw_data[:message], raw_data[:last_code], mode: :activerecord
136
+ )
137
+ return {
138
+ question: question,
139
+ content: explanation,
140
+ code: raw_data[:last_code],
141
+ code_type: "activerecord",
142
+ successful: false
143
+ }
144
+ end
145
+
146
+ status_callback&.call(:humanizing)
147
+ {
148
+ question: question,
149
+ content: Glancer::Workflow::LLM.humanized_response(question, raw_data, code, mode: :activerecord),
150
+ code: code,
151
+ code_type: "activerecord",
152
+ successful: true,
153
+ sources: embeddings.map { |e| { id: e.id, type: e.source_type, path: e.source_path, score: e.score } }
154
+ }
155
+ end
156
+ private_class_method :run_activerecord
157
+ end
158
+ end
data/lib/glancer.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "glancer/version"
4
+ require "glancer/configuration"
5
+
6
+ module Glancer
7
+ class Error < StandardError; end
8
+
9
+ class << self
10
+ attr_accessor :configuration
11
+ end
12
+
13
+ def self.configure
14
+ self.configuration ||= Configuration.new
15
+ yield(configuration)
16
+ end
17
+ end
18
+
19
+ require "glancer/utils/logger" # Glancer::Utils::Logger
20
+ require "glancer/utils/markdown_helper" # Glancer::Utils::MarkdownHelper
21
+ require "glancer/utils/result_formatter" # Glancer::Utils::ResultFormatter
22
+ require "glancer/utils/table_stats" # Glancer::Utils::TableStats
23
+ require "glancer/utils/transaction" # Glancer::Utils::Transaction
24
+
25
+ require "glancer/engine" # Glancer::Engine
26
+
27
+ require "glancer/indexer" # Glancer::Indexer
28
+ require "glancer/indexer/context_indexer" # Glancer::Indexer::ContextIndexer
29
+ require "glancer/indexer/model_indexer" # Glancer::Indexer::ModelIndexer
30
+ require "glancer/indexer/schema_indexer" # Glancer::Indexer::SchemaIndexer
31
+
32
+ require "glancer/retriever" # Glancer::Retriever
33
+
34
+ require "glancer/workflow" # Glancer::Workflow
35
+ require "glancer/workflow/builder" # Glancer::Workflow::Builder
36
+ require "glancer/workflow/cache" # Glancer::Workflow::Cache
37
+ require "glancer/workflow/executor" # Glancer::Workflow::Executor
38
+ require "glancer/workflow/prompt_builder" # Glancer::Workflow::PromptBuilder
39
+ require "glancer/workflow/sql_extractor" # Glancer::Workflow::SQLExtractor
40
+ require "glancer/workflow/sql_sanitizer" # Glancer::Workflow::SQLSanitizer
41
+ require "glancer/workflow/sql_validator" # Glancer::Workflow::SQLValidator
42
+ require "glancer/workflow/llm" # Glancer::Workflow::LLM
43
+ require "glancer/workflow/ar_prompt_builder" # Glancer::Workflow::ARPromptBuilder
44
+ require "glancer/workflow/ar_extractor" # Glancer::Workflow::ARExtractor
45
+ require "glancer/workflow/ar_sanitizer" # Glancer::Workflow::ARSanitizer
46
+ require "glancer/workflow/ar_executor" # Glancer::Workflow::ARExecutor
47
+ require "glancer/workflow/query_enricher" # Glancer::Workflow::QueryEnricher
48
+
49
+ require "glancer/chart_analyzer" # Glancer::ChartAnalyzer
50
+ require "glancer/async_runner" # Glancer::AsyncRunner
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :glancer do
4
+ desc "Install Tailwind CSS"
5
+ task :install do
6
+ system "#{RbConfig.ruby} ./bin/rails app:tailwindcss:install"
7
+ end
8
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :glancer do
4
+ desc "Show current Glancer and RubyLLM versions"
5
+ task :version do
6
+ puts "\n\e[36m✱ glancer@v#{Glancer::VERSION}\e[0m\n"
7
+ puts
8
+ end
9
+
10
+ namespace :index do
11
+ desc "Rebuild all Glancer indexes"
12
+ task all: :environment do
13
+ existing = Glancer::Embedding.count
14
+ if existing.positive?
15
+ puts "\n\e[33m✱ There are currently #{existing} embeddings stored.\e[0m"
16
+ puts "\e[31m└→ This operation will delete all existing embeddings and reindex everything.\e[0m"
17
+ end
18
+
19
+ Glancer::Utils::Transaction.make do
20
+ Glancer::Embedding.where(source_type: %w[schema models context]).delete_all
21
+
22
+ Glancer::Utils::Logger.with_debug_logs do
23
+ Glancer::Indexer.rebuild_all!
24
+ end
25
+
26
+ puts "\e[32m✔ All indexes rebuilt!\e[0m"
27
+ end
28
+ end
29
+
30
+ desc "Rebuild schema index only"
31
+ task schema: :environment do
32
+ if confirm_rebuild?(:schema)
33
+ Glancer::Utils::Transaction.make do
34
+ Glancer::Embedding.where(source_type: "schema").delete_all
35
+
36
+ Glancer::Utils::Logger.with_debug_logs do
37
+ chunks = Glancer::Indexer::SchemaIndexer.index!
38
+ Glancer::Retriever.store_documents(chunks)
39
+ end
40
+
41
+ puts "\e[32m✔ Schema index rebuilt!\e[0m"
42
+ end
43
+ end
44
+ end
45
+
46
+ desc "Rebuild models index only"
47
+ task models: :environment do
48
+ if confirm_rebuild?(:models)
49
+ Glancer::Utils::Transaction.make do
50
+ Glancer::Embedding.where(source_type: "models").delete_all
51
+
52
+ Glancer::Utils::Logger.with_debug_logs do
53
+ chunks = Glancer::Indexer::ModelIndexer.index!
54
+ Glancer::Retriever.store_documents(chunks)
55
+ end
56
+
57
+ puts "\e[32m✔ Models index rebuilt!\e[0m"
58
+ end
59
+ end
60
+ end
61
+
62
+ desc "Rebuild context index only"
63
+ task context: :environment do
64
+ if confirm_rebuild?(:context)
65
+ Glancer::Utils::Transaction.make do
66
+ Glancer::Embedding.where(source_type: "context").delete_all
67
+
68
+ Glancer::Utils::Logger.with_debug_logs do
69
+ chunks = Glancer::Indexer::ContextIndexer.index!
70
+ Glancer::Retriever.store_documents(chunks)
71
+ end
72
+
73
+ puts "\e[32m✔ Context index rebuilt!\e[0m"
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ def confirm_rebuild?(type)
80
+ existing = Glancer::Embedding.where(source_type: type.to_s)
81
+ if existing.exists?
82
+ last = existing.order(created_at: :desc).first.created_at
83
+ puts "\n\e[33m✱ Existing #{existing.count} '#{type}' embeddings found. Last updated: #{last.strftime("%Y-%m-%d %H:%M:%S")}.\e[0m"
84
+ print "\e[31m└→ Do you want to delete and reindex? [y/N]: \e[0m"
85
+ input = $stdin.gets.strip.upcase
86
+
87
+ puts "\n\e[31m✖ Operation cancelled.\e[0m\n" unless input == "Y"
88
+
89
+ return input == "Y"
90
+ end
91
+ true
92
+ rescue Interrupt
93
+ puts "\n\n\e[31m✖ Operation cancelled\e[0m\n"
94
+ false
95
+ rescue StandardError => e
96
+ puts "\n\n\e[31m✖ Error: #{e.message}\e[0m\n"
97
+ false
98
+ end
99
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer do
6
+ describe "::VERSION" do
7
+ it "is defined and non-nil" do
8
+ expect(Glancer::VERSION).not_to be_nil
9
+ end
10
+
11
+ it "is a String" do
12
+ expect(Glancer::VERSION).to be_a(String)
13
+ end
14
+
15
+ it "follows semantic versioning format" do
16
+ expect(Glancer::VERSION).to match(/\A\d+\.\d+\.\d+/)
17
+ end
18
+ end
19
+
20
+ describe "::Error" do
21
+ it "is a subclass of StandardError" do
22
+ expect(Glancer::Error.ancestors).to include(StandardError)
23
+ end
24
+
25
+ it "can be raised and rescued" do
26
+ expect { raise Glancer::Error, "test" }.to raise_error(Glancer::Error, "test")
27
+ end
28
+ end
29
+
30
+ describe ".configure" do
31
+ it "yields the configuration object" do
32
+ Glancer.configure do |c|
33
+ expect(c).to be_a(Glancer::Configuration)
34
+ end
35
+ end
36
+
37
+ it "persists changes made inside the block" do
38
+ Glancer.configure { |c| c.k = 42 }
39
+ expect(Glancer.configuration.k).to eq(42)
40
+ end
41
+
42
+ it "re-uses the existing configuration on repeated calls" do
43
+ Glancer.configure { |c| c.k = 7 }
44
+ Glancer.configure { |c| c.min_score = 0.5 }
45
+ expect(Glancer.configuration.k).to eq(7)
46
+ expect(Glancer.configuration.min_score).to eq(0.5)
47
+ end
48
+ end
49
+
50
+ describe ".configuration" do
51
+ it "is accessible after configure" do
52
+ expect(Glancer.configuration).to be_a(Glancer::Configuration)
53
+ end
54
+
55
+ it "can be replaced by assignment" do
56
+ new_config = Glancer::Configuration.new
57
+ new_config.k = 99
58
+ Glancer.configuration = new_config
59
+ expect(Glancer.configuration.k).to eq(99)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::AsyncRunner do
6
+ let(:chat) { Glancer::Chat.create!(title: "Test") }
7
+ let(:message) do
8
+ Glancer::Message.create!(
9
+ chat: chat, role: :assistant, content: "", code_type: "sql", status: :processing
10
+ )
11
+ end
12
+ let(:question) { "How many users?" }
13
+
14
+ let(:workflow_result) do
15
+ {
16
+ content: "There are 5 users.",
17
+ code: "SELECT COUNT(*) FROM users",
18
+ code_type: "sql",
19
+ successful: true,
20
+ enriched_question: nil
21
+ }
22
+ end
23
+
24
+ before do
25
+ allow(Glancer::Workflow).to receive(:run).and_return(workflow_result)
26
+ allow(Glancer::Workflow::LLM).to receive(:generate_title).and_return("User count")
27
+ # Stub resolved_chat_provider/model so the model string builds without config errors
28
+ allow(Glancer.configuration).to receive(:resolved_chat_provider).and_return("gemini")
29
+ allow(Glancer.configuration).to receive(:resolved_chat_model).and_return("gemini-2.0-flash")
30
+ end
31
+
32
+ # ── .run ──────────────────────────────────────────────────────────────────
33
+
34
+ describe ".run" do
35
+ it "updates the message status to :processing then :complete" do
36
+ described_class.run(message.id, question)
37
+ expect(message.reload.status).to eq("complete")
38
+ end
39
+
40
+ it "stores the workflow content on the message" do
41
+ described_class.run(message.id, question)
42
+ expect(message.reload.content).to eq("There are 5 users.")
43
+ end
44
+
45
+ it "stores the generated code on the message" do
46
+ described_class.run(message.id, question)
47
+ expect(message.reload.code).to eq("SELECT COUNT(*) FROM users")
48
+ end
49
+
50
+ it "marks the message as successful" do
51
+ described_class.run(message.id, question)
52
+ expect(message.reload.successful).to be(true)
53
+ end
54
+
55
+ it "creates a code_version record for the generated code" do
56
+ expect { described_class.run(message.id, question) }
57
+ .to change(Glancer::CodeVersion, :count).by(1)
58
+ end
59
+
60
+ it "does not create a code_version when the result has no code" do
61
+ allow(Glancer::Workflow).to receive(:run).and_return(workflow_result.merge(code: nil))
62
+ expect { described_class.run(message.id, question) }
63
+ .not_to change(Glancer::CodeVersion, :count)
64
+ end
65
+
66
+ it "updates the chat title on the first user message" do
67
+ chat.messages.create!(role: :user, content: question)
68
+ described_class.run(message.id, question)
69
+ expect(chat.reload.title).to eq("User count")
70
+ end
71
+
72
+ it "does not update the chat title when multiple user messages exist" do
73
+ 2.times { chat.messages.create!(role: :user, content: question) }
74
+ expect(Glancer::Workflow::LLM).not_to receive(:generate_title)
75
+ described_class.run(message.id, question)
76
+ end
77
+
78
+ context "when the workflow raises" do
79
+ before { allow(Glancer::Workflow).to receive(:run).and_raise(StandardError, "LLM down") }
80
+
81
+ it "marks the message as failed" do
82
+ described_class.run(message.id, question)
83
+ expect(message.reload.status).to eq("failed")
84
+ end
85
+
86
+ it "stores the error message as content" do
87
+ described_class.run(message.id, question)
88
+ expect(message.reload.content).to eq("LLM down")
89
+ end
90
+
91
+ it "marks the message as not successful" do
92
+ described_class.run(message.id, question)
93
+ expect(message.reload.successful).to be(false)
94
+ end
95
+ end
96
+
97
+ context "when both workflow and the rescue update raise" do
98
+ before do
99
+ allow(Glancer::Workflow).to receive(:run).and_raise(StandardError, "boom")
100
+ allow_any_instance_of(Glancer::Message).to receive(:update!).and_call_original
101
+ allow_any_instance_of(Glancer::Message).to receive(:update!)
102
+ .with(hash_including(status: :failed))
103
+ .and_raise(StandardError, "db gone")
104
+ end
105
+
106
+ it "does not propagate the secondary error" do
107
+ expect { described_class.run(message.id, question) }.not_to raise_error
108
+ end
109
+ end
110
+ end
111
+
112
+ # ── .call ─────────────────────────────────────────────────────────────────
113
+
114
+ describe ".call" do
115
+ it "returns a Thread" do
116
+ t = described_class.call(message.id, question)
117
+ t.join
118
+ expect(t).to be_a(Thread)
119
+ end
120
+
121
+ it "executes .run inside the thread" do
122
+ expect(described_class).to receive(:run).with(message.id, question)
123
+ described_class.call(message.id, question).join
124
+ end
125
+
126
+ it "does not raise when the thread encounters an error outside with_connection" do
127
+ msg_id = message.id # force lazy let evaluation before stubbing connection_pool
128
+ allow(ActiveRecord::Base).to receive(:connection_pool)
129
+ .and_raise(StandardError, "pool error")
130
+ expect { described_class.call(msg_id, question).join }.not_to raise_error
131
+ end
132
+ end
133
+ end