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,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
|