sql-chatbot-rails 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/LICENSE +21 -0
- data/README.md +20 -0
- data/app/controllers/sql_chatbot/chatbot_controller.rb +158 -0
- data/config/routes.rb +11 -0
- data/lib/generators/sql_chatbot/install_generator.rb +25 -0
- data/lib/generators/sql_chatbot/templates/initializer.rb +22 -0
- data/lib/sql_chatbot/auth/cors.rb +35 -0
- data/lib/sql_chatbot/auth/jwt.rb +34 -0
- data/lib/sql_chatbot/configuration.rb +58 -0
- data/lib/sql_chatbot/engine.rb +23 -0
- data/lib/sql_chatbot/grammar/count_renderer.rb +113 -0
- data/lib/sql_chatbot/grammar/entity_candidates.rb +210 -0
- data/lib/sql_chatbot/grammar/intent_extractor.rb +191 -0
- data/lib/sql_chatbot/grammar/list_renderer.rb +50 -0
- data/lib/sql_chatbot/grammar/miss_logger.rb +17 -0
- data/lib/sql_chatbot/grammar/modifiers.rb +145 -0
- data/lib/sql_chatbot/grammar/primitives.rb +69 -0
- data/lib/sql_chatbot/grammar/programmatic_renderer.rb +258 -0
- data/lib/sql_chatbot/grammar/registry.rb +66 -0
- data/lib/sql_chatbot/grammar/sanity_check.rb +37 -0
- data/lib/sql_chatbot/grammar/template_compiler.rb +179 -0
- data/lib/sql_chatbot/llm/client.rb +87 -0
- data/lib/sql_chatbot/prompts/answer.rb +157 -0
- data/lib/sql_chatbot/prompts/classify.rb +59 -0
- data/lib/sql_chatbot/prompts/generate_sql.rb +88 -0
- data/lib/sql_chatbot/services/code_indexer.rb +337 -0
- data/lib/sql_chatbot/services/grammar_pipeline.rb +45 -0
- data/lib/sql_chatbot/services/model_introspector.rb +152 -0
- data/lib/sql_chatbot/services/orchestrator.rb +635 -0
- data/lib/sql_chatbot/services/registry_builder.rb +385 -0
- data/lib/sql_chatbot/services/route_introspector.rb +118 -0
- data/lib/sql_chatbot/services/schema_service.rb +884 -0
- data/lib/sql_chatbot/services/sql_executor.rb +81 -0
- data/lib/sql_chatbot/version.rb +5 -0
- data/lib/sql_chatbot_rails.rb +91 -0
- data/vendor/assets/widget.js +53 -0
- metadata +180 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module SqlChatbot
|
|
2
|
+
module Services
|
|
3
|
+
module SqlExecutor
|
|
4
|
+
KEYWORD_BLOCKLIST = %w[INSERT UPDATE DELETE DROP ALTER CREATE GRANT TRUNCATE EXECUTE REVOKE COPY INTO].freeze
|
|
5
|
+
FUNCTION_BLOCKLIST = %w[pg_read_file pg_read_binary_file dblink pg_terminate_backend lo_import lo_export pg_sleep set_config current_setting].freeze
|
|
6
|
+
CATALOG_BLOCKLIST = %w[pg_shadow pg_roles pg_authid pg_user information_schema].freeze
|
|
7
|
+
AGGREGATE_PATTERN = /\b(COUNT|SUM|AVG|MIN|MAX)\s*\(/i
|
|
8
|
+
|
|
9
|
+
def self.validate_sql(sql)
|
|
10
|
+
trimmed = sql.gsub(/^\s+/, "")
|
|
11
|
+
trimmed = trimmed.gsub(/--[^\n]*/, "").gsub(/\/\*[\s\S]*?\*\//, "")
|
|
12
|
+
trimmed = trimmed.strip
|
|
13
|
+
|
|
14
|
+
# Fix missing space after SELECT
|
|
15
|
+
trimmed = trimmed.sub(/^SELECT(?=[A-Z])/i, "SELECT ")
|
|
16
|
+
|
|
17
|
+
# Single statement check
|
|
18
|
+
parts = trimmed.split(";").select { |p| p.strip.length > 0 }
|
|
19
|
+
if parts.length > 1
|
|
20
|
+
return { valid: false, reason: "Only a single statement is allowed" }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
working_sql = trimmed.sub(/;\s*$/, "").strip
|
|
24
|
+
|
|
25
|
+
# Must start with SELECT
|
|
26
|
+
unless working_sql.match?(/^SELECT\b/i)
|
|
27
|
+
return { valid: false, reason: "Only SELECT queries are allowed" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Keyword blocklist
|
|
31
|
+
KEYWORD_BLOCKLIST.each do |keyword|
|
|
32
|
+
if working_sql.match?(/\b#{keyword}\b/i)
|
|
33
|
+
return { valid: false, reason: "Blocked keyword: #{keyword}" }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Function blocklist
|
|
38
|
+
FUNCTION_BLOCKLIST.each do |fn|
|
|
39
|
+
if working_sql.match?(/\b#{fn}\b/i)
|
|
40
|
+
return { valid: false, reason: "Blocked function: #{fn}" }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Catalog blocklist
|
|
45
|
+
CATALOG_BLOCKLIST.each do |catalog|
|
|
46
|
+
if working_sql.match?(/\b#{catalog}\b/i)
|
|
47
|
+
return { valid: false, reason: "Blocked system catalog: #{catalog}" }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Auto-add LIMIT
|
|
52
|
+
has_limit = working_sql.match?(/\bLIMIT\b/i)
|
|
53
|
+
is_aggregate = AGGREGATE_PATTERN.match?(working_sql)
|
|
54
|
+
working_sql += " LIMIT 500" if !has_limit && !is_aggregate
|
|
55
|
+
|
|
56
|
+
{ valid: true, sql: working_sql }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.execute_sql(sql)
|
|
60
|
+
connection = ActiveRecord::Base.connection
|
|
61
|
+
connection.execute("SET statement_timeout = '10s'")
|
|
62
|
+
connection.execute("BEGIN")
|
|
63
|
+
connection.execute("SET TRANSACTION READ ONLY")
|
|
64
|
+
|
|
65
|
+
result = connection.execute(sql)
|
|
66
|
+
rows = result.to_a
|
|
67
|
+
columns = rows.first&.keys || []
|
|
68
|
+
|
|
69
|
+
connection.execute("COMMIT")
|
|
70
|
+
|
|
71
|
+
{ columns: columns, rows: rows, row_count: rows.length }
|
|
72
|
+
rescue => e
|
|
73
|
+
connection.execute("ROLLBACK") rescue nil
|
|
74
|
+
raise e
|
|
75
|
+
ensure
|
|
76
|
+
# Reset statement_timeout on shared AR connection
|
|
77
|
+
connection.execute("SET statement_timeout = '0'") rescue nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sql_chatbot/version"
|
|
4
|
+
require "sql_chatbot/configuration"
|
|
5
|
+
require "sql_chatbot/llm/client"
|
|
6
|
+
require "sql_chatbot/prompts/classify"
|
|
7
|
+
require "sql_chatbot/prompts/generate_sql"
|
|
8
|
+
require "sql_chatbot/prompts/answer"
|
|
9
|
+
require "sql_chatbot/services/sql_executor"
|
|
10
|
+
require "sql_chatbot/services/schema_service"
|
|
11
|
+
require "sql_chatbot/services/code_indexer"
|
|
12
|
+
require "sql_chatbot/services/orchestrator"
|
|
13
|
+
require "sql_chatbot/services/model_introspector"
|
|
14
|
+
require "sql_chatbot/services/route_introspector"
|
|
15
|
+
require "sql_chatbot/auth/jwt"
|
|
16
|
+
require "sql_chatbot/auth/cors"
|
|
17
|
+
require "sql_chatbot/engine" if defined?(Rails)
|
|
18
|
+
|
|
19
|
+
module SqlChatbot
|
|
20
|
+
class << self
|
|
21
|
+
attr_accessor :config, :schema_service, :code_indexer, :orchestrator, :registry
|
|
22
|
+
|
|
23
|
+
def configure
|
|
24
|
+
self.config ||= Configuration.new
|
|
25
|
+
yield(config) if block_given?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset!
|
|
29
|
+
self.config = Configuration.new
|
|
30
|
+
@schema_service = nil
|
|
31
|
+
@code_indexer = nil
|
|
32
|
+
@orchestrator = nil
|
|
33
|
+
@registry = nil
|
|
34
|
+
@initialized = false
|
|
35
|
+
@init_mutex = Mutex.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ensure_initialized!
|
|
39
|
+
return if @initialized
|
|
40
|
+
@init_mutex ||= Mutex.new
|
|
41
|
+
@init_mutex.synchronize do
|
|
42
|
+
return if @initialized
|
|
43
|
+
cfg = config || Configuration.new
|
|
44
|
+
|
|
45
|
+
@schema_service = Services::SchemaService.new
|
|
46
|
+
@schema_service.discover
|
|
47
|
+
|
|
48
|
+
# Introspect Rails models for enums, non-standard FKs, and soft delete gems
|
|
49
|
+
introspector = Services::ModelIntrospector.new
|
|
50
|
+
introspection = introspector.introspect
|
|
51
|
+
|
|
52
|
+
# Apply soft delete annotations conditionally (gem-based vs enum-based)
|
|
53
|
+
@schema_service.apply_soft_delete_annotations(
|
|
54
|
+
soft_delete_tables: introspection.soft_delete_tables,
|
|
55
|
+
enum_soft_delete_tables: introspection.enum_soft_delete_tables,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Inject model annotations (enums, FKs)
|
|
59
|
+
@schema_service.append_model_annotations(introspection.annotations)
|
|
60
|
+
|
|
61
|
+
# Move lookup values from referenced tables to FK columns
|
|
62
|
+
@schema_service.relocate_lookup_annotations
|
|
63
|
+
|
|
64
|
+
# Introspect Rails routes for navigation context
|
|
65
|
+
route_introspector = Services::RouteIntrospector.new
|
|
66
|
+
route_data = route_introspector.introspect
|
|
67
|
+
|
|
68
|
+
@code_indexer = Services::CodeIndexer.new
|
|
69
|
+
@code_indexer.index(cfg.code_paths)
|
|
70
|
+
|
|
71
|
+
llm_client = LLM::Client.new(
|
|
72
|
+
api_key: cfg.resolved_api_key,
|
|
73
|
+
base_url: cfg.resolved_base_url,
|
|
74
|
+
model: cfg.resolved_model,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@orchestrator = Services::Orchestrator.new(
|
|
78
|
+
llm_client: llm_client,
|
|
79
|
+
schema_service: @schema_service,
|
|
80
|
+
code_indexer: @code_indexer,
|
|
81
|
+
route_introspector_data: route_data,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@initialized = true
|
|
85
|
+
end
|
|
86
|
+
rescue => e
|
|
87
|
+
@initialized = false
|
|
88
|
+
raise e
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|