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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ module Indexer
5
+ module ContextIndexer
6
+ module_function
7
+
8
+ def index!
9
+ Glancer::Utils::Logger.info("Indexer::ContextIndexer", "Starting context indexing...")
10
+
11
+ path = Glancer.configuration.context_file_path
12
+
13
+ if path.nil? || !File.exist?(path)
14
+ Glancer::Utils::Logger.error("Indexer::ContextIndexer", "Context file not found at path: #{path.inspect}")
15
+ raise Glancer::Error, "Context file not found. Expected at: #{path.inspect}"
16
+ end
17
+
18
+ Glancer::Utils::Logger.debug("Indexer::ContextIndexer", "Reading context file from: #{path}")
19
+ content = File.read(path)
20
+ Glancer::Utils::Logger.debug("Indexer::ContextIndexer", "Read #{content.bytesize} bytes from context file")
21
+
22
+ if content.lines.first&.strip == "--glancer-ignore"
23
+ Glancer::Utils::Logger.info("Indexer::ContextIndexer",
24
+ "Context file marked with --glancer-ignore, skipping indexing.")
25
+ return []
26
+ end
27
+
28
+ chunks = split_into_chunks(content)
29
+ Glancer::Utils::Logger.info("Indexer::ContextIndexer", "Split content into #{chunks.size} chunk(s)")
30
+
31
+ chunks.map do |chunk|
32
+ {
33
+ content: chunk,
34
+ source_type: "context",
35
+ source_path: path
36
+ }
37
+ end
38
+ rescue StandardError => e
39
+ Glancer::Utils::Logger.error("Indexer::ContextIndexer",
40
+ "Failed to index context: #{e.class} - #{e.message}")
41
+ Glancer::Utils::Logger.debug("Indexer::ContextIndexer", "Backtrace:\n#{e.backtrace.join("\n")}")
42
+ raise Glancer::Error, "Context indexing failed: #{e.message}"
43
+ end
44
+
45
+ def split_into_chunks(text)
46
+ size = Glancer.configuration.chunk_size
47
+ overlap = Glancer.configuration.chunk_overlap
48
+ chunks = []
49
+ start = 0
50
+ while start < text.length
51
+ chunks << text[start, size]
52
+ start += size - overlap
53
+ end
54
+ chunks
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ module Indexer
5
+ module ModelIndexer
6
+ module_function
7
+
8
+ def index!
9
+ Glancer::Utils::Logger.info("Indexer::ModelIndexer", "Starting model indexing...")
10
+
11
+ model_files = Dir[Rails.root.join("app/models/**/*.rb")]
12
+
13
+ if model_files.empty?
14
+ Glancer::Utils::Logger.warn("Indexer::ModelIndexer", "No model files found for indexing.")
15
+ return []
16
+ end
17
+
18
+ Glancer::Utils::Logger.info("Indexer::ModelIndexer", "Found #{model_files.size} model file(s)")
19
+
20
+ all_chunks = []
21
+
22
+ model_files.each do |file|
23
+ Glancer::Utils::Logger.debug("Indexer::ModelIndexer", "Reading model file: #{file}")
24
+
25
+ content = File.read(file)
26
+ Glancer::Utils::Logger.debug("Indexer::ModelIndexer", "Read #{content.bytesize} bytes from #{file}")
27
+
28
+ chunks = split_into_chunks(content)
29
+ Glancer::Utils::Logger.debug("Indexer::ModelIndexer", "Split into #{chunks.size} chunk(s)")
30
+
31
+ all_chunks.concat(
32
+ chunks.map do |chunk|
33
+ {
34
+ content: chunk,
35
+ source_type: "models",
36
+ source_path: file
37
+ }
38
+ end
39
+ )
40
+ end
41
+
42
+ Glancer::Utils::Logger.info("Indexer::ModelIndexer", "Completed indexing. Total chunks: #{all_chunks.size}")
43
+
44
+ all_chunks
45
+ rescue StandardError => e
46
+ Glancer::Utils::Logger.error("Indexer::ModelIndexer", "Model indexing failed: #{e.class} - #{e.message}")
47
+ Glancer::Utils::Logger.debug("Indexer::ModelIndexer", "Backtrace:\n#{e.backtrace.join("\n")}")
48
+ raise Glancer::Error, "Model indexing failed: #{e.message}"
49
+ end
50
+
51
+ def split_into_chunks(text)
52
+ size = Glancer.configuration.chunk_size
53
+ overlap = Glancer.configuration.chunk_overlap
54
+ chunks = []
55
+ start = 0
56
+ while start < text.length
57
+ chunks << text[start, size]
58
+ start += size - overlap
59
+ end
60
+ chunks
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ module Indexer
5
+ module SchemaIndexer
6
+ module_function
7
+
8
+ def index!
9
+ Glancer::Utils::Logger.info("Indexer::SchemaIndexer", "Starting schema indexing...")
10
+
11
+ schema_file = Rails.root.join("db/schema.rb")
12
+
13
+ unless File.exist?(schema_file)
14
+ Glancer::Utils::Logger.warn("Indexer::SchemaIndexer", "Schema file not found at: #{schema_file}")
15
+ return []
16
+ end
17
+
18
+ Glancer::Utils::Logger.debug("Indexer::SchemaIndexer", "Reading schema file from: #{schema_file}")
19
+
20
+ content = File.read(schema_file)
21
+ Glancer::Utils::Logger.debug("Indexer::SchemaIndexer", "Read #{content.bytesize} bytes from schema file")
22
+
23
+ eager_load_models!
24
+
25
+ chunks = split_into_chunks(content)
26
+ Glancer::Utils::Logger.info("Indexer::SchemaIndexer", "Found #{chunks.size} table definition(s) in schema")
27
+
28
+ indexed_chunks = chunks.map do |chunk|
29
+ table_name = extract_table_name(chunk)
30
+ if table_name
31
+ Glancer::Utils::Logger.debug("Indexer::SchemaIndexer", "Indexed table: #{table_name}")
32
+ enriched = chunk + model_associations_block(table_name)
33
+ {
34
+ content: enriched,
35
+ source_type: "schema",
36
+ source_path: "#{schema_file}##{table_name}"
37
+ }
38
+ else
39
+ Glancer::Utils::Logger.warn("Indexer::SchemaIndexer", "Could not extract table name from chunk")
40
+ nil
41
+ end
42
+ end.compact
43
+
44
+ fk_chunk = extract_foreign_keys(content, schema_file)
45
+ indexed_chunks << fk_chunk if fk_chunk
46
+
47
+ inflections_chunk = extract_inflections
48
+ indexed_chunks << inflections_chunk if inflections_chunk
49
+
50
+ Glancer::Utils::Logger.info("Indexer::SchemaIndexer",
51
+ "Completed schema indexing. Total indexed chunks: #{indexed_chunks.size}")
52
+
53
+ indexed_chunks
54
+ rescue StandardError => e
55
+ Glancer::Utils::Logger.error("Indexer::SchemaIndexer", "Schema indexing failed: #{e.class} - #{e.message}")
56
+ Glancer::Utils::Logger.debug("Indexer::SchemaIndexer", "Backtrace:\n#{e.backtrace.join("\n")}")
57
+ raise Glancer::Error, "Schema indexing failed: #{e.message}"
58
+ end
59
+
60
+ def eager_load_models!
61
+ Rails.application.eager_load!
62
+ Glancer::Utils::Logger.debug("Indexer::SchemaIndexer", "Models eager-loaded for association reflection")
63
+ rescue StandardError => e
64
+ Glancer::Utils::Logger.warn("Indexer::SchemaIndexer", "Could not eager-load models: #{e.message}")
65
+ end
66
+
67
+ def find_model_for_table(table_name)
68
+ candidates = ActiveRecord::Base.descendants.select do |model|
69
+ !model.abstract_class? &&
70
+ !model.name&.start_with?("Glancer::") &&
71
+ model.table_name == table_name
72
+ end
73
+ return nil if candidates.empty?
74
+
75
+ candidates.find { |m| m.superclass.abstract_class? || m.superclass == ActiveRecord::Base } || candidates.first
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
80
+ def model_associations_block(table_name)
81
+ model = find_model_for_table(table_name)
82
+ return "" unless model
83
+
84
+ assocs = model.reflect_on_all_associations
85
+ return "" if assocs.empty?
86
+
87
+ lines = assocs.filter_map do |assoc|
88
+ format_association(assoc)
89
+ rescue StandardError
90
+ nil
91
+ end
92
+ return "" if lines.empty?
93
+
94
+ "\n\n# ActiveRecord Associations (#{model.name}):\n#{lines.join("\n")}"
95
+ end
96
+
97
+ def format_association(assoc)
98
+ parts = [" #{assoc.macro} :#{assoc.name}"]
99
+ opts = ["class_name: \"#{assoc.class_name}\""]
100
+
101
+ fk = assoc.foreign_key.to_s
102
+ opts << "foreign_key: \"#{fk}\"" if fk.present?
103
+ opts << "through: :#{assoc.options[:through]}" if assoc.options[:through].present?
104
+ opts << "polymorphic: true" if assoc.options[:polymorphic]
105
+ opts << "as: :#{assoc.options[:as]}" if assoc.options[:as].present?
106
+ opts << "source: :#{assoc.options[:source]}" if assoc.options[:source].present?
107
+ opts << "dependent: :#{assoc.options[:dependent]}" if assoc.options[:dependent].present?
108
+
109
+ "#{parts.join} (#{opts.join(", ")})"
110
+ end
111
+
112
+ def extract_inflections
113
+ inflections_file = Rails.root.join("config/initializers/inflections.rb")
114
+ return nil unless File.exist?(inflections_file)
115
+
116
+ raw = File.read(inflections_file)
117
+ return nil unless raw.lines.any? { |l| l.strip.match?(/\binflect\.\w/) }
118
+
119
+ Glancer::Utils::Logger.debug("Indexer::SchemaIndexer", "Found custom inflections, adding as schema chunk")
120
+ {
121
+ content: "# Custom Rails Inflections\n# These control plural/singular model name mapping.\n\n#{raw.strip}",
122
+ source_type: "schema",
123
+ source_path: inflections_file.to_s
124
+ }
125
+ rescue StandardError => e
126
+ Glancer::Utils::Logger.warn("Indexer::SchemaIndexer", "Could not read inflections: #{e.message}")
127
+ nil
128
+ end
129
+
130
+ def split_into_chunks(schema_text)
131
+ schema_text.split(/^ create_table /).map do |chunk|
132
+ next if chunk.strip.empty?
133
+
134
+ "create_table #{chunk.strip}"
135
+ end.compact
136
+ end
137
+
138
+ def extract_table_name(chunk)
139
+ chunk[/create_table ["']?([a-zA-Z0-9_]+)["']?/, 1]
140
+ end
141
+
142
+ def extract_foreign_keys(schema_text, schema_file)
143
+ lines = schema_text.lines.select { |l| l.strip.start_with?("add_foreign_key") }
144
+ return nil if lines.empty?
145
+
146
+ relationships = lines.filter_map do |line|
147
+ # add_foreign_key "orders", "users", column: "user_id"
148
+ # add_foreign_key "order_items", "orders"
149
+ m = line.match(/add_foreign_key ["'](\w+)["'],\s*["'](\w+)["'](?:.*column:\s*["'](\w+)["'])?/)
150
+ next unless m
151
+
152
+ child_table = m[1]
153
+ parent_table = m[2]
154
+ column = m[3] || "#{parent_table.singularize}_id"
155
+ "#{child_table}.#{column} → #{parent_table}.id"
156
+ end
157
+
158
+ return nil if relationships.empty?
159
+
160
+ content = "# Foreign Key Relationships\n#{relationships.join("\n")}"
161
+ Glancer::Utils::Logger.debug("Indexer::SchemaIndexer", "Extracted #{relationships.size} foreign key(s)")
162
+
163
+ {
164
+ content: content,
165
+ source_type: "schema",
166
+ source_path: "#{schema_file}#foreign_keys"
167
+ }
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "indexer/schema_indexer"
4
+ require_relative "indexer/model_indexer"
5
+ require_relative "indexer/context_indexer"
6
+
7
+ module Glancer
8
+ module Indexer
9
+ module_function
10
+
11
+ def rebuild_all!
12
+ Glancer::Utils::Logger.info("Indexer", "Starting full index rebuild...")
13
+
14
+ chunks = []
15
+
16
+ if Glancer.configuration.schema_permission
17
+ Glancer::Utils::Logger.info("Indexer", "Indexing schema (enabled by configuration)...")
18
+ chunks += SchemaIndexer.index!
19
+ else
20
+ Glancer::Utils::Logger.debug("Indexer", "Schema indexing is disabled in configuration.")
21
+ end
22
+
23
+ if Glancer.configuration.models_permission
24
+ Glancer::Utils::Logger.info("Indexer", "Indexing models (enabled by configuration)...")
25
+ chunks += ModelIndexer.index!
26
+ else
27
+ Glancer::Utils::Logger.debug("Indexer", "Model indexing is disabled in configuration.")
28
+ end
29
+
30
+ if Glancer.configuration.context_file_path
31
+ Glancer::Utils::Logger.info("Indexer", "Indexing context file (path configured)...")
32
+ chunks += ContextIndexer.index!
33
+ else
34
+ Glancer::Utils::Logger.debug("Indexer", "No context file path configured. Skipping context indexing.")
35
+ end
36
+
37
+ Glancer::Utils::Logger.info("Indexer", "Indexing completed. Total chunks: #{chunks.size}")
38
+
39
+ Glancer::Utils::Logger.debug("Indexer", "Storing documents into retriever...")
40
+ Retriever.store_documents(chunks)
41
+ Glancer::Utils::Logger.info("Indexer", "Documents stored successfully.")
42
+
43
+ chunks
44
+ rescue StandardError => e
45
+ Glancer::Utils::Logger.error("Indexer", "Index rebuilding failed: #{e.class} - #{e.message}")
46
+ Glancer::Utils::Logger.debug("Indexer", "Backtrace:\n#{e.backtrace.join("\n")}")
47
+ raise Glancer::Error, "Index rebuilding failed: #{e.message}"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Glancer
6
+ module Retriever
7
+ module_function
8
+
9
+ def store_documents(chunks_with_metadata)
10
+ Glancer::Utils::Logger.info("Retriever", "Storing #{chunks_with_metadata.size} document chunk(s)...")
11
+
12
+ chunks_with_metadata.each_with_index do |data, idx|
13
+ chunk = data[:content]
14
+ preview = chunk[0..50].gsub(/\s+/, " ").strip
15
+
16
+ Glancer::Utils::Logger.debug("Retriever",
17
+ "Embedding chunk ##{idx + 1} (#{data[:source_type]} - #{data[:source_path]}): '#{preview}...'")
18
+
19
+ vector = RubyLLM.embed(
20
+ chunk,
21
+ model: Glancer.configuration.resolved_embedding_model,
22
+ provider: Glancer.configuration.resolved_embedding_provider,
23
+ assume_model_exists: true
24
+ ).vectors
25
+
26
+ Glancer::Utils::Logger.debug("Retriever",
27
+ "Vector size: #{vector.size}, example values: #{vector.first(5).inspect}")
28
+
29
+ Glancer::Embedding.create!(
30
+ content: chunk,
31
+ embedding: vector,
32
+ source_type: data[:source_type],
33
+ source_path: data[:source_path]
34
+ )
35
+
36
+ Glancer::Utils::Logger.info("Retriever",
37
+ "Stored chunk ##{idx + 1} from #{data[:source_type]}: #{data[:source_path]}")
38
+ end
39
+
40
+ Glancer::Utils::Logger.info("Retriever", "All chunks stored successfully.")
41
+ rescue StandardError => e
42
+ Glancer::Utils::Logger.error("Retriever", "Failed to store document chunks: #{e.class} - #{e.message}")
43
+ Glancer::Utils::Logger.debug("Retriever", "Backtrace:\n#{e.backtrace.join("\n")}")
44
+ raise Glancer::Error, "Document storage failed: #{e.message}"
45
+ end
46
+
47
+ def search(query)
48
+ Glancer::Utils::Logger.info("Retriever", "Searching for top #{Glancer.configuration.k} results...")
49
+
50
+ query_embedding = RubyLLM.embed(
51
+ query,
52
+ model: Glancer.configuration.resolved_embedding_model,
53
+ provider: Glancer.configuration.resolved_embedding_provider,
54
+ assume_model_exists: true
55
+ ).vectors
56
+
57
+ # @TODO Postgres with native search?
58
+ perform_ruby_search(query_embedding)
59
+ end
60
+
61
+ def perform_ruby_search(query_embedding)
62
+ results = Glancer::Embedding.all.map do |record|
63
+ # Calculate similarity between query and stored document
64
+ score = cosine_similarity(query_embedding, record.embedding)
65
+ weighted_score = score * weight_for(record.source_type)
66
+
67
+ { record: record, score: weighted_score }
68
+ end
69
+
70
+ sorted = results.sort_by { |r| -r[:score] }
71
+
72
+ # Filter by min_score threshold
73
+ top_matches = sorted
74
+ .select { |r| r[:score] >= Glancer.configuration.min_score }
75
+ .first(Glancer.configuration.k)
76
+
77
+ # Fallback: if nothing passes the threshold, use best available results so the
78
+ # LLM always has some schema context rather than generating blind code.
79
+ if top_matches.empty? && sorted.any?
80
+ top_matches = sorted.first(Glancer.configuration.k)
81
+ Glancer::Utils::Logger.warn("Retriever",
82
+ "No results above min_score (#{Glancer.configuration.min_score}); " \
83
+ "using top #{top_matches.size} result(s) as fallback")
84
+ end
85
+
86
+ top_matches = top_matches.map do |r|
87
+ r[:record].tap do |record|
88
+ record.define_singleton_method(:score) { r[:score] }
89
+ end
90
+ end
91
+
92
+ Glancer::Utils::Logger.info("Retriever", "Found #{top_matches.size} relevant document(s)")
93
+ top_matches
94
+ end
95
+
96
+ def weight_for(source_type)
97
+ case source_type
98
+ when "schema" then Glancer.configuration.schema_documents_weight
99
+ when "context" then Glancer.configuration.context_documents_weight
100
+ when "models" then Glancer.configuration.models_documents_weight
101
+ else 1.0
102
+ end
103
+ end
104
+
105
+ def cosine_similarity(vec1, vec2)
106
+ dot = vec1.zip(vec2).map { |a, b| a * b }.sum
107
+ mag1 = Math.sqrt(vec1.sum { |x| x**2 })
108
+ mag2 = Math.sqrt(vec2.sum { |x| x**2 })
109
+ return 0.0 if mag1.zero? || mag2.zero?
110
+
111
+ dot / (mag1 * mag2)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ module Utils
5
+ class Logger
6
+ VERBOSITY_LEVELS = {
7
+ silent: -2,
8
+ none: -1,
9
+ info: 1,
10
+ debug: 2
11
+ }.freeze
12
+
13
+ COLORS = {
14
+ debug: "\e[36m",
15
+ info: "\e[32m",
16
+ warn: "\e[33m",
17
+ error: "\e[31m",
18
+ reset: "\e[0m"
19
+ }.freeze
20
+
21
+ # EMOJIS = {
22
+ # debug: "🔍",
23
+ # info: "✅",
24
+ # warn: "⚠️",
25
+ # error: "❌"
26
+ # }.freeze
27
+
28
+ class << self
29
+ def debug(tag, message)
30
+ write(:debug, tag, message)
31
+ end
32
+
33
+ def info(tag, message)
34
+ write(:info, tag, message)
35
+ end
36
+
37
+ def warn(tag, message)
38
+ write(:warn, tag, message)
39
+ end
40
+
41
+ def error(tag, message)
42
+ write(:error, tag, message)
43
+ end
44
+
45
+ private
46
+
47
+ def write(level, tag, message)
48
+ verbosity = begin
49
+ Glancer.configuration.log_verbosity.to_sym
50
+ rescue StandardError
51
+ :info
52
+ end
53
+
54
+ return if verbosity == :silent
55
+ return if %i[info debug].include?(level) &&
56
+ VERBOSITY_LEVELS[level] > VERBOSITY_LEVELS[verbosity]
57
+
58
+ # emoji = EMOJIS[level] || ""
59
+ color = COLORS[level] || ""
60
+ reset = COLORS[:reset]
61
+
62
+ timestamp = Time.current.strftime("%Y-%m-%d %H:%M:%S")
63
+ # line = "#{emoji} [#{timestamp}] [Glancer::#{tag}] #{message}"
64
+ line = "[#{timestamp}] [Glancer::#{tag}] #{message}"
65
+
66
+ if Glancer.configuration&.log_output_path
67
+ File.open(Glancer.configuration.log_output_path, "a") { |f| f.puts(line) }
68
+ else
69
+ puts("#{color}#{line}#{reset}")
70
+ end
71
+ end
72
+ end
73
+
74
+ def self.with_debug_logs
75
+ old = Glancer.configuration.log_verbosity
76
+ Glancer.configuration.log_verbosity = :debug
77
+ yield
78
+ ensure
79
+ Glancer.configuration.log_verbosity = old
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "commonmarker"
4
+ module Glancer
5
+ module Utils
6
+ class MarkdownHelper
7
+ def self.markdown_to_html(markdown_text, schema_base: nil, valid_tables: nil)
8
+ content = Commonmarker.to_html(highlight_mentions(markdown_text, schema_base: schema_base, valid_tables: valid_tables),
9
+ options: {
10
+ parse: { smart: true },
11
+ render: { unsafe: true, github_pre_lang: true }
12
+ },
13
+ plugins: { syntax_highlighter: { theme: "InspiredGitHub" } })
14
+
15
+ content.gsub!(%r{<table.*?</table>}m) do |table_html|
16
+ %(<div class="table-scroll-wrapper"><div class="table-scroll-inner">#{table_html}</div></div>)
17
+ end
18
+
19
+ content
20
+ end
21
+
22
+ # Wraps @word tokens with a highlight span. Applied before markdown so that
23
+ # the renderer preserves the inline HTML. Skips content inside backtick spans
24
+ # and fenced code blocks so code examples are not affected.
25
+ def self.highlight_mentions(text, schema_base: nil, valid_tables: nil)
26
+ valid_set = valid_tables&.to_set
27
+ # Split on fenced code blocks and inline backtick spans to skip them.
28
+ parts = text.split(/(```[\s\S]*?```|`[^`]*`)/)
29
+ parts.map.with_index do |part, idx|
30
+ if idx.odd?
31
+ part
32
+ else
33
+ # Negative lookbehind prevents matching @ inside emails or identifiers.
34
+ part.gsub(/(?<![a-zA-Z0-9._])@([a-zA-Z]\w*)/) do
35
+ table = Regexp.last_match(1)
36
+ next "@#{table}" if valid_set && !valid_set.include?(table)
37
+
38
+ if schema_base
39
+ href = "#{schema_base}?table=#{table}"
40
+ attrs = %( href="#{href}" target="_blank" rel="noopener noreferrer" tabindex="0")
41
+ %(<a class="glancer-mention" data-table="#{table}"#{attrs}>@#{table}</a>)
42
+ else
43
+ %(<a class="glancer-mention" data-table="#{table}" href="#" tabindex="0">@#{table}</a>)
44
+ end
45
+ end
46
+ end
47
+ end.join
48
+ end
49
+
50
+ def self.extract_sql_from_markdown(markdown)
51
+ match = markdown.match(/```sql\n(.+?)\n```/m)
52
+ match ? match[1].strip : ""
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ module Utils
5
+ class ResultFormatter
6
+ def self.normalize(rows)
7
+ return rows if rows.empty?
8
+
9
+ keys = rows.first.keys
10
+
11
+ if rows.all? { |r| r.keys.sort == keys.sort }
12
+ normalized = {}
13
+
14
+ keys.each do |key|
15
+ normalized[key] = rows.map { |row| row[key] }
16
+ end
17
+
18
+ normalized
19
+ else
20
+ rows
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glancer
4
+ module Utils
5
+ module TableStats
6
+ module_function
7
+
8
+ def count_for(table_name)
9
+ return -1 unless Glancer::Configuration.valid_table_name?(table_name)
10
+
11
+ ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{table_name}").to_i
12
+ rescue StandardError => e
13
+ Glancer::Utils::Logger.warn("TableStats", "Could not count rows in #{table_name}: #{e.message}")
14
+ -1
15
+ end
16
+ end
17
+ end
18
+ end