asktive_record 0.1.7 → 0.2.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 +4 -4
- data/.rubocop.yml +19 -1
- data/CHANGELOG.md +52 -1
- data/README.md +196 -114
- data/lib/asktive_record/adapters/base.rb +56 -0
- data/lib/asktive_record/adapters/openai.rb +62 -0
- data/lib/asktive_record/configuration.rb +37 -4
- data/lib/asktive_record/llm_service.rb +33 -56
- data/lib/asktive_record/log.rb +49 -0
- data/lib/asktive_record/model.rb +7 -43
- data/lib/asktive_record/prompt.rb +105 -54
- data/lib/asktive_record/query.rb +37 -26
- data/lib/asktive_record/schema_loader.rb +63 -0
- data/lib/asktive_record/service.rb +4 -50
- data/lib/asktive_record/sql_sanitizer.rb +92 -0
- data/lib/asktive_record/version.rb +1 -1
- data/lib/asktive_record.rb +36 -2
- data/lib/generators/asktive_record/templates/asktive_record_initializer.rb +29 -6
- data/sig/asktive_record.rbs +177 -1
- metadata +18 -29
data/lib/asktive_record/query.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "asktive_record/sql_sanitizer"
|
|
4
|
+
require "asktive_record/log"
|
|
5
|
+
|
|
3
6
|
module AsktiveRecord
|
|
4
7
|
# The Query class encapsulates a natural language question, its corresponding SQL,
|
|
5
8
|
# and provides methods for sanitization, execution, and generating answers using LLMs.
|
|
@@ -12,25 +15,27 @@ module AsktiveRecord
|
|
|
12
15
|
@model_class = model_class
|
|
13
16
|
@natural_question = natural_question
|
|
14
17
|
@sanitized_sql = raw_sql # Initially, sanitized SQL is the same as raw SQL
|
|
18
|
+
|
|
19
|
+
AsktiveRecord::Log.info("Received question: \"#{natural_question}\"")
|
|
20
|
+
AsktiveRecord::Log.info("Generated SQL: #{raw_sql}")
|
|
15
21
|
end
|
|
16
22
|
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
23
|
+
# Sanitizes the SQL using the SqlSanitizer.
|
|
24
|
+
# Raises AsktiveRecord::SanitizationError if the query is unsafe.
|
|
25
|
+
#
|
|
26
|
+
# @param allow_only_select [Boolean] restrict to SELECT-only (default: true)
|
|
27
|
+
# @return [self] for chaining
|
|
20
28
|
def sanitize!(allow_only_select: true)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Add more sanitization rules here as needed
|
|
29
|
+
@sanitized_sql = SqlSanitizer.sanitize!(@sanitized_sql, allow_only_select: allow_only_select)
|
|
30
|
+
AsktiveRecord::Log.info("Sanitized SQL: #{@sanitized_sql}")
|
|
26
31
|
self # Return self for chaining
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def answer
|
|
30
35
|
response = execute
|
|
31
36
|
llm = AsktiveRecord::LlmService.new(AsktiveRecord.configuration)
|
|
32
|
-
|
|
33
|
-
llm.answer(@natural_question, @sanitized_sql,
|
|
37
|
+
response_text = response.respond_to?(:inspect) ? response.inspect : response.to_s
|
|
38
|
+
llm.answer(@natural_question, @sanitized_sql, response_text)
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def execute
|
|
@@ -39,8 +44,15 @@ module AsktiveRecord
|
|
|
39
44
|
"Cannot execute raw SQL. Call sanitize! first or work with sanitized_sql."
|
|
40
45
|
end
|
|
41
46
|
|
|
47
|
+
# Auto-sanitize before execution if not already done
|
|
48
|
+
validate_before_execution!
|
|
49
|
+
|
|
42
50
|
result = execute_query
|
|
43
|
-
|
|
51
|
+
AsktiveRecord::Log.info("Query executed successfully.")
|
|
52
|
+
AsktiveRecord::Log.debug("Query results: #{result.inspect}")
|
|
53
|
+
result
|
|
54
|
+
rescue QueryExecutionError
|
|
55
|
+
raise
|
|
44
56
|
rescue StandardError => e
|
|
45
57
|
raise QueryExecutionError, "Failed to execute SQL query: #{e.message}"
|
|
46
58
|
end
|
|
@@ -51,27 +63,32 @@ module AsktiveRecord
|
|
|
51
63
|
|
|
52
64
|
private
|
|
53
65
|
|
|
66
|
+
def validate_before_execution!
|
|
67
|
+
# Always validate the SQL is safe before execution
|
|
68
|
+
SqlSanitizer.sanitize!(@sanitized_sql, allow_only_select: read_only_mode?)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def read_only_mode?
|
|
72
|
+
AsktiveRecord.configuration&.read_only != false
|
|
73
|
+
end
|
|
74
|
+
|
|
54
75
|
def execute_query
|
|
55
76
|
if active_record_model?
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
result
|
|
77
|
+
AsktiveRecord::Log.info("Executing SQL via #{model_class.name}.find_by_sql")
|
|
78
|
+
model_class.find_by_sql(@sanitized_sql)
|
|
60
79
|
else
|
|
61
80
|
execute_raw_sql
|
|
62
81
|
end
|
|
63
82
|
end
|
|
64
83
|
|
|
65
84
|
def active_record_model?
|
|
66
|
-
model_class.respond_to?(:find_by_sql) && model_class.table_name.present?
|
|
85
|
+
model_class.respond_to?(:find_by_sql) && model_class.respond_to?(:table_name) && model_class.table_name.present?
|
|
67
86
|
end
|
|
68
87
|
|
|
69
88
|
def execute_raw_sql
|
|
70
89
|
return unless defined?(ActiveRecord::Base)
|
|
71
90
|
|
|
72
|
-
|
|
73
|
-
# no-op here unless you're logging or observing
|
|
74
|
-
end
|
|
91
|
+
AsktiveRecord::Log.info("Executing SQL via ActiveRecord::Base.connection")
|
|
75
92
|
|
|
76
93
|
if select_query?
|
|
77
94
|
ActiveRecord::Base.connection.select_all(@sanitized_sql)
|
|
@@ -81,13 +98,7 @@ module AsktiveRecord
|
|
|
81
98
|
end
|
|
82
99
|
|
|
83
100
|
def select_query?
|
|
84
|
-
@sanitized_sql.strip.
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def extract_count_if_present(result)
|
|
88
|
-
return result unless result.is_a?(Array) && result[0].is_a?(Hash) && result[0].key?("count")
|
|
89
|
-
|
|
90
|
-
result[0]["count"]
|
|
101
|
+
@sanitized_sql.strip.match?(/\ASELECT\b/i)
|
|
91
102
|
end
|
|
92
103
|
end
|
|
93
104
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "asktive_record/log"
|
|
4
|
+
|
|
5
|
+
module AsktiveRecord
|
|
6
|
+
# SchemaLoader provides shared schema loading functionality used by both
|
|
7
|
+
# Model::ClassMethods and Service::ClassMethods. It handles reading schema files,
|
|
8
|
+
# falling back to structure.sql, and providing clear error messages.
|
|
9
|
+
module SchemaLoader
|
|
10
|
+
# Loads the database schema content from the configured path.
|
|
11
|
+
# Falls back to db/structure.sql if the primary schema file is not found.
|
|
12
|
+
#
|
|
13
|
+
# @return [String] the schema content
|
|
14
|
+
# @raise [ConfigurationError] if no schema file is found or content is empty
|
|
15
|
+
def load_schema_content
|
|
16
|
+
path = AsktiveRecord.configuration.db_schema_path
|
|
17
|
+
return File.read(path) if File.exist?(path)
|
|
18
|
+
|
|
19
|
+
attempt_schema_fallback(path)
|
|
20
|
+
rescue SystemCallError => e
|
|
21
|
+
raise ConfigurationError, "Error reading schema file at #{path}: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validates that the schema content is not empty.
|
|
25
|
+
#
|
|
26
|
+
# @param schema_content [String] the schema content to validate
|
|
27
|
+
# @raise [ConfigurationError] if schema content is blank
|
|
28
|
+
def ensure_schema_is_not_empty!(schema_content)
|
|
29
|
+
return unless schema_content.to_s.strip.empty?
|
|
30
|
+
|
|
31
|
+
raise ConfigurationError,
|
|
32
|
+
"Schema content is empty. Cannot proceed without database schema context."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Validates that the LLM API key is configured.
|
|
36
|
+
#
|
|
37
|
+
# @raise [ConfigurationError] if the API key is not set
|
|
38
|
+
def validate_llm_api_key!
|
|
39
|
+
return if AsktiveRecord.configuration&.llm_api_key
|
|
40
|
+
|
|
41
|
+
raise ConfigurationError, "LLM API key is not configured for AsktiveRecord."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def attempt_schema_fallback(path)
|
|
47
|
+
AsktiveRecord::Log.warn(
|
|
48
|
+
"Schema file not found at #{path}. " \
|
|
49
|
+
"Run 'bundle exec rails generate asktive_record:setup' for robust schema handling."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
alt_path = "db/structure.sql"
|
|
53
|
+
if File.exist?(alt_path)
|
|
54
|
+
AsktiveRecord::Log.info("Using schema from #{alt_path}")
|
|
55
|
+
return File.read(alt_path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raise ConfigurationError,
|
|
59
|
+
"Database schema file not found at #{path}. AsktiveRecord needs schema context. " \
|
|
60
|
+
"Ensure the schema file is present or configure the correct path."
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "asktive_record/llm_service"
|
|
4
|
+
require "asktive_record/schema_loader"
|
|
5
|
+
require "asktive_record/log"
|
|
4
6
|
|
|
5
7
|
module AsktiveRecord
|
|
6
8
|
module Service
|
|
@@ -9,6 +11,8 @@ module AsktiveRecord
|
|
|
9
11
|
# It allows for more complex queries that may involve multiple tables or relationships,
|
|
10
12
|
# without requiring the user to specify a target table.
|
|
11
13
|
module ClassMethods
|
|
14
|
+
include AsktiveRecord::SchemaLoader
|
|
15
|
+
|
|
12
16
|
def ask(natural_language_query, options = {})
|
|
13
17
|
validate_llm_api_key!
|
|
14
18
|
schema_content = load_schema_content
|
|
@@ -22,56 +26,6 @@ module AsktiveRecord
|
|
|
22
26
|
|
|
23
27
|
private
|
|
24
28
|
|
|
25
|
-
def ensure_schema_is_not_empty!(schema_content)
|
|
26
|
-
return unless schema_content.to_s.strip.empty?
|
|
27
|
-
|
|
28
|
-
raise ConfigurationError,
|
|
29
|
-
"Schema content is empty. Cannot proceed without database schema context."
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def validate_llm_api_key!
|
|
33
|
-
return if AsktiveRecord.configuration&.llm_api_key
|
|
34
|
-
|
|
35
|
-
raise ConfigurationError, "LLM API key is not configured for AsktiveRecord."
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def load_schema_content
|
|
39
|
-
path = AsktiveRecord.configuration.db_schema_path
|
|
40
|
-
return File.read(path) if File.exist?(path)
|
|
41
|
-
|
|
42
|
-
attempt_schema_fallback(path)
|
|
43
|
-
rescue SystemCallError => e
|
|
44
|
-
raise ConfigurationError, "Error reading schema file at #{path}: #{e.message}"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def attempt_schema_fallback(path)
|
|
48
|
-
puts "Schema file not found at #{path}. Attempting to generate it. " \
|
|
49
|
-
"Run 'bundle exec asktive_record:setup' for robust schema handling."
|
|
50
|
-
|
|
51
|
-
return fallback_schema_from_rails(path) if defined?(Rails)
|
|
52
|
-
|
|
53
|
-
raise ConfigurationError,
|
|
54
|
-
"Database schema file not found at #{path}. AsktiveRecord needs schema context. " \
|
|
55
|
-
"Run in a Rails environment or ensure the schema file is present."
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def fallback_schema_from_rails(path)
|
|
59
|
-
system("bin/rails db:schema:dump") unless AsktiveRecord.configuration.skip_dump_schema
|
|
60
|
-
return File.read(path) if File.exist?(path)
|
|
61
|
-
|
|
62
|
-
alt_path = "db/structure.sql"
|
|
63
|
-
return use_alternative_schema(alt_path) if File.exist?(alt_path)
|
|
64
|
-
|
|
65
|
-
raise ConfigurationError,
|
|
66
|
-
"Database schema file not found at #{path} or #{alt_path} even after attempting to dump. " \
|
|
67
|
-
"Please run asktive_record:setup or configure the correct path."
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def use_alternative_schema(path)
|
|
71
|
-
puts "Using schema from #{path}"
|
|
72
|
-
File.read(path)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
29
|
def resolve_model(provided_model)
|
|
76
30
|
return provided_model if provided_model
|
|
77
31
|
return ApplicationRecord if defined?(Rails) && defined?(ApplicationRecord)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsktiveRecord
|
|
4
|
+
# SqlSanitizer provides robust SQL sanitization to prevent injection attacks
|
|
5
|
+
# from LLM-generated queries. It validates that queries are safe SELECT statements
|
|
6
|
+
# and blocks dangerous patterns like DDL, DML, and injection techniques.
|
|
7
|
+
class SqlSanitizer
|
|
8
|
+
# SQL keywords that indicate dangerous operations
|
|
9
|
+
DANGEROUS_KEYWORDS = %w[
|
|
10
|
+
INSERT UPDATE DELETE DROP ALTER CREATE TRUNCATE
|
|
11
|
+
REPLACE MERGE GRANT REVOKE EXEC EXECUTE
|
|
12
|
+
CALL RENAME LOAD COPY INTO OUTFILE DUMPFILE
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# Patterns that indicate SQL injection attempts
|
|
16
|
+
INJECTION_PATTERNS = [
|
|
17
|
+
/;\s*\S/i, # Semicolon followed by another statement
|
|
18
|
+
/--\s/, # SQL line comment
|
|
19
|
+
%r{/\*.*?\*/}m, # SQL block comment
|
|
20
|
+
/\bUNION\b.*\bSELECT\b/i, # UNION-based injection
|
|
21
|
+
/\bINTO\s+OUTFILE\b/i, # File write attempt
|
|
22
|
+
/\bINTO\s+DUMPFILE\b/i, # File dump attempt
|
|
23
|
+
/\bLOAD_FILE\b/i, # File read attempt
|
|
24
|
+
/\bBENCHMARK\b/i, # Time-based injection
|
|
25
|
+
/\bSLEEP\b\s*\(/i, # Time-based injection
|
|
26
|
+
/\bIF\b\s*\(/i, # Conditional injection (IF function)
|
|
27
|
+
/\bCASE\s+WHEN\b.*\bTHEN\b.*\bWAITFOR\b/i, # Conditional time-based
|
|
28
|
+
/\bWAITFOR\s+DELAY\b/i, # MSSQL time-based injection
|
|
29
|
+
/\bpg_sleep\b/i, # PostgreSQL time-based injection
|
|
30
|
+
/\bDBMS_LOCK\.SLEEP\b/i # Oracle time-based injection
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Sanitizes the given SQL string.
|
|
35
|
+
# Raises AsktiveRecord::SanitizationError if the query is unsafe.
|
|
36
|
+
#
|
|
37
|
+
# @param sql [String] the SQL string to sanitize
|
|
38
|
+
# @param allow_only_select [Boolean] whether to restrict to SELECT-only (default: true)
|
|
39
|
+
# @return [String] the sanitized SQL string
|
|
40
|
+
def sanitize!(sql, allow_only_select: true)
|
|
41
|
+
raise SanitizationError, "SQL query cannot be nil or empty." if sql.nil? || sql.strip.empty?
|
|
42
|
+
|
|
43
|
+
cleaned = clean_sql(sql)
|
|
44
|
+
|
|
45
|
+
validate_select_only!(cleaned) if allow_only_select
|
|
46
|
+
|
|
47
|
+
check_dangerous_keywords!(cleaned)
|
|
48
|
+
check_injection_patterns!(cleaned)
|
|
49
|
+
|
|
50
|
+
cleaned
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Strips trailing semicolons and extra whitespace
|
|
56
|
+
def clean_sql(sql)
|
|
57
|
+
sql.strip.gsub(/;\s*\z/, "")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Validates the query starts with SELECT
|
|
61
|
+
def validate_select_only!(sql)
|
|
62
|
+
return if sql.strip.match?(/\ASELECT\b/i)
|
|
63
|
+
|
|
64
|
+
raise SanitizationError,
|
|
65
|
+
"Query sanitization failed: Only SELECT statements are allowed. Got: #{sql.split.first}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Checks for dangerous DDL/DML keywords in the query
|
|
69
|
+
def check_dangerous_keywords!(sql)
|
|
70
|
+
# Remove string literals to avoid false positives on quoted content
|
|
71
|
+
sql_without_strings = sql.gsub(/'[^']*'/, "''").gsub(/"[^"]*"/, '""')
|
|
72
|
+
|
|
73
|
+
DANGEROUS_KEYWORDS.each do |keyword|
|
|
74
|
+
next unless sql_without_strings.match?(/\b#{keyword}\b/i)
|
|
75
|
+
|
|
76
|
+
raise SanitizationError,
|
|
77
|
+
"Query sanitization failed: Dangerous SQL keyword '#{keyword}' detected."
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Checks for common SQL injection patterns
|
|
82
|
+
def check_injection_patterns!(sql)
|
|
83
|
+
INJECTION_PATTERNS.each do |pattern|
|
|
84
|
+
next unless sql.match?(pattern)
|
|
85
|
+
|
|
86
|
+
raise SanitizationError,
|
|
87
|
+
"Query sanitization failed: Potentially dangerous SQL pattern detected."
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/asktive_record.rb
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "asktive_record/version"
|
|
4
4
|
require "asktive_record/error"
|
|
5
|
+
require "asktive_record/log"
|
|
5
6
|
require "asktive_record/configuration"
|
|
7
|
+
require "asktive_record/sql_sanitizer"
|
|
8
|
+
require "asktive_record/schema_loader"
|
|
6
9
|
require "asktive_record/model"
|
|
7
10
|
require "asktive_record/service"
|
|
8
11
|
require "asktive_record/query"
|
|
@@ -13,6 +16,12 @@ module AsktiveRecord
|
|
|
13
16
|
extend Service::ClassMethods
|
|
14
17
|
class << self
|
|
15
18
|
attr_accessor :configuration
|
|
19
|
+
|
|
20
|
+
# Provides access to the logger instance
|
|
21
|
+
# @return [AsktiveRecord::Log]
|
|
22
|
+
def logger
|
|
23
|
+
AsktiveRecord::Log
|
|
24
|
+
end
|
|
16
25
|
end
|
|
17
26
|
|
|
18
27
|
def self.configure
|
|
@@ -34,7 +43,32 @@ module AsktiveRecord
|
|
|
34
43
|
|
|
35
44
|
# Class method to allow direct querying from AsktiveRecord module
|
|
36
45
|
def self.ask(natural_language_query, options = {})
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
validate_configuration!
|
|
47
|
+
schema_content = load_schema_for_module
|
|
48
|
+
llm_service = AsktiveRecord::LlmService.new(configuration)
|
|
49
|
+
target_table = options[:table_name] || "any"
|
|
50
|
+
raw_sql = llm_service.generate_sql_for_service(natural_language_query, schema_content, target_table)
|
|
51
|
+
target_model = options[:model] || self
|
|
52
|
+
AsktiveRecord::Query.new(natural_language_query, raw_sql, target_model)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
def self.validate_configuration!
|
|
57
|
+
return if configuration&.llm_api_key
|
|
58
|
+
|
|
59
|
+
raise ConfigurationError, "LLM API key is not configured for AsktiveRecord."
|
|
39
60
|
end
|
|
61
|
+
|
|
62
|
+
# @api private
|
|
63
|
+
def self.load_schema_for_module
|
|
64
|
+
path = configuration.db_schema_path
|
|
65
|
+
raise ConfigurationError, "Schema file not found at #{path}." unless File.exist?(path)
|
|
66
|
+
|
|
67
|
+
content = File.read(path)
|
|
68
|
+
raise ConfigurationError, "Schema content is empty." if content.strip.empty?
|
|
69
|
+
|
|
70
|
+
content
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private_class_method :validate_configuration!, :load_schema_for_module
|
|
40
74
|
end
|
|
@@ -10,25 +10,48 @@ AsktiveRecord.configure do |config|
|
|
|
10
10
|
# === LLM API Key ===
|
|
11
11
|
# Set your API key for the chosen LLM provider.
|
|
12
12
|
# It is strongly recommended to use environment variables for sensitive data.
|
|
13
|
-
# For
|
|
14
|
-
|
|
15
|
-
config.llm_api_key = "YOUR_OPENAI_API_KEY_HERE"
|
|
13
|
+
# For OpenAI:
|
|
14
|
+
config.llm_api_key = ENV.fetch("OPENAI_API_KEY", nil)
|
|
16
15
|
|
|
17
16
|
# === LLM Model Name ===
|
|
18
17
|
# Specify the model name for the LLM provider if applicable.
|
|
19
|
-
# For OpenAI, default is "gpt-
|
|
20
|
-
# config.llm_model_name = "gpt-
|
|
18
|
+
# For OpenAI, default is "gpt-4o-mini". Other models like "gpt-4o" can be used.
|
|
19
|
+
# config.llm_model_name = "gpt-4o-mini"
|
|
21
20
|
|
|
22
21
|
# === Database Schema Path ===
|
|
23
22
|
# Path to your Rails application's schema file (usually schema.rb or structure.sql).
|
|
24
23
|
# This is used by the `asktive_record:setup` command to provide context to the LLM.
|
|
25
24
|
# Default is "db/schema.rb".
|
|
26
25
|
# config.db_schema_path = "db/schema.rb"
|
|
27
|
-
|
|
26
|
+
|
|
28
27
|
# === Skip dump schema ===
|
|
29
28
|
# If set to true, the schema will not be dumped when running the
|
|
30
29
|
# `asktive_record:setup` command.
|
|
31
30
|
# This is useful if you want to manage schema dumps manually
|
|
32
31
|
# or if you are using a different schema management strategy.
|
|
33
32
|
# config.skip_dump_schema = false
|
|
33
|
+
|
|
34
|
+
# === Read-Only Mode ===
|
|
35
|
+
# When true (default), only SELECT queries are allowed to execute.
|
|
36
|
+
# Set to false if you want to allow other query types (not recommended).
|
|
37
|
+
# config.read_only = true
|
|
38
|
+
|
|
39
|
+
# === LLM Temperature ===
|
|
40
|
+
# Controls randomness in LLM responses. Lower = more deterministic.
|
|
41
|
+
# Default is 0.2 (low randomness for reliable SQL generation).
|
|
42
|
+
# config.temperature = 0.2
|
|
43
|
+
|
|
44
|
+
# === LLM Max Tokens ===
|
|
45
|
+
# Maximum number of tokens in the LLM response.
|
|
46
|
+
# Default is 250.
|
|
47
|
+
# config.max_tokens = 250
|
|
48
|
+
|
|
49
|
+
# === Custom Adapter ===
|
|
50
|
+
# Provide a custom LLM adapter instead of using the built-in provider.
|
|
51
|
+
# The adapter must inherit from AsktiveRecord::Adapters::Base and implement #chat.
|
|
52
|
+
# config.adapter = MyCustomAdapter.new(api_key: ENV["MY_LLM_KEY"])
|
|
53
|
+
|
|
54
|
+
# === Custom Logger ===
|
|
55
|
+
# Set a custom logger. Defaults to Rails.logger when available.
|
|
56
|
+
# config.logger = Logger.new($stdout)
|
|
34
57
|
end
|
data/sig/asktive_record.rbs
CHANGED
|
@@ -1,4 +1,180 @@
|
|
|
1
1
|
module AsktiveRecord
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
def self.configure: () { (Configuration) -> void } -> void
|
|
5
|
+
def self.configuration: () -> Configuration?
|
|
6
|
+
def self.configuration=: (Configuration?) -> void
|
|
7
|
+
def self.logger: () -> untyped
|
|
8
|
+
def self.logger=: (untyped) -> void
|
|
9
|
+
def self.ask: (String, ?Hash[Symbol, untyped]) -> Query
|
|
10
|
+
def self.validate_configuration!: () -> void
|
|
11
|
+
def self.included: (Module) -> void
|
|
12
|
+
|
|
13
|
+
class Configuration
|
|
14
|
+
attr_accessor llm_provider: Symbol
|
|
15
|
+
attr_accessor llm_api_key: String?
|
|
16
|
+
attr_accessor llm_model_name: String
|
|
17
|
+
attr_accessor db_schema_path: String
|
|
18
|
+
attr_accessor skip_dump_schema: bool
|
|
19
|
+
attr_accessor logger: untyped
|
|
20
|
+
attr_accessor read_only: bool
|
|
21
|
+
attr_accessor adapter: Adapters::Base?
|
|
22
|
+
attr_accessor temperature: Float
|
|
23
|
+
attr_accessor max_tokens: Integer
|
|
24
|
+
attr_accessor cache_enabled: bool
|
|
25
|
+
|
|
26
|
+
def initialize: () -> void
|
|
27
|
+
def resolved_adapter: () -> Adapters::Base
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_adapter_from_provider: () -> Adapters::Base
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class LlmService
|
|
35
|
+
attr_reader configuration: Configuration
|
|
36
|
+
|
|
37
|
+
def initialize: (Configuration) -> void
|
|
38
|
+
def upload_schema: (String) -> bool
|
|
39
|
+
def answer: (String, String, untyped) -> String?
|
|
40
|
+
def generate_sql: (String, String, String) -> String
|
|
41
|
+
def generate_sql_for_service: (String, String, ?String) -> String
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def answer_as_human: (String, String, untyped) -> String?
|
|
46
|
+
def adapter: () -> Adapters::Base
|
|
47
|
+
def llm_options: () -> Hash[Symbol, untyped]
|
|
48
|
+
def generate_and_validate_sql: (String) -> String
|
|
49
|
+
def validate_sql_response!: (String?) -> void
|
|
50
|
+
def clean_sql: (String) -> String
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class Query
|
|
54
|
+
attr_reader raw_sql: String
|
|
55
|
+
attr_reader model_class: untyped
|
|
56
|
+
attr_reader natural_question: String
|
|
57
|
+
|
|
58
|
+
def initialize: (String, String, untyped) -> void
|
|
59
|
+
def sanitize!: (?allow_only_select: bool) -> self
|
|
60
|
+
def answer: () -> String?
|
|
61
|
+
def execute: () -> untyped
|
|
62
|
+
def to_s: () -> String
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def validate_before_execution!: () -> void
|
|
67
|
+
def read_only_mode?: () -> bool
|
|
68
|
+
def execute_query: () -> untyped
|
|
69
|
+
def execute_raw_sql: () -> untyped
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class Prompt
|
|
73
|
+
PROMPT_INJECTION_PATTERNS: Array[Regexp]
|
|
74
|
+
|
|
75
|
+
def self.as_human_answerer: (String, String, untyped) -> String
|
|
76
|
+
def self.as_sql_generator: (String, String) -> String
|
|
77
|
+
def self.as_sql_generator_for_model: (String, String, String) -> String
|
|
78
|
+
def self.escape_input: (String) -> String
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class SqlSanitizer
|
|
82
|
+
DANGEROUS_KEYWORDS: Array[String]
|
|
83
|
+
INJECTION_PATTERNS: Array[Regexp]
|
|
84
|
+
|
|
85
|
+
def self.sanitize!: (String?, ?allow_only_select: bool) -> String
|
|
86
|
+
def self.dangerous_keyword?: (String) -> bool
|
|
87
|
+
def self.injection_pattern?: (String) -> bool
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def self.strip_quoted_strings: (String) -> String
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
module Log
|
|
95
|
+
PREFIX: String
|
|
96
|
+
|
|
97
|
+
def self.logger: () -> untyped
|
|
98
|
+
def self.logger=: (untyped) -> void
|
|
99
|
+
def self.info: (String) -> void
|
|
100
|
+
def self.debug: (String) -> void
|
|
101
|
+
def self.warn: (String) -> void
|
|
102
|
+
def self.error: (String) -> void
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def self.default_logger: () -> untyped
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
module SchemaLoader
|
|
110
|
+
def load_schema_content: () -> String
|
|
111
|
+
def ensure_schema_is_not_empty!: (String) -> void
|
|
112
|
+
def validate_llm_api_key!: () -> void
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
module Model
|
|
116
|
+
module ClassMethods
|
|
117
|
+
include SchemaLoader
|
|
118
|
+
|
|
119
|
+
def asktive_record: () -> void
|
|
120
|
+
def ask: (String) -> Query
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
module Service
|
|
125
|
+
module ClassMethods
|
|
126
|
+
include SchemaLoader
|
|
127
|
+
|
|
128
|
+
def ask: (String, ?Hash[Symbol, untyped]) -> Query
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def resolve_model: (untyped) -> untyped
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
module Adapters
|
|
137
|
+
class Base
|
|
138
|
+
attr_reader api_key: String
|
|
139
|
+
attr_reader model_name: String?
|
|
140
|
+
|
|
141
|
+
def initialize: (api_key: String, ?model_name: String?) -> void
|
|
142
|
+
def chat: (String, ?Hash[Symbol, untyped]) -> String?
|
|
143
|
+
def default_model_name: () -> String
|
|
144
|
+
def resolved_model_name: () -> String
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class OpenAI < Base
|
|
148
|
+
DEFAULT_MODEL: String
|
|
149
|
+
DEFAULT_TEMPERATURE: Float
|
|
150
|
+
DEFAULT_MAX_TOKENS: Integer
|
|
151
|
+
|
|
152
|
+
def initialize: (api_key: String, ?model_name: String?) -> void
|
|
153
|
+
def chat: (String, ?Hash[Symbol, untyped]) -> String?
|
|
154
|
+
def default_model_name: () -> String
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def client: () -> untyped
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Error classes
|
|
163
|
+
class Error < StandardError
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class ConfigurationError < Error
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
class QueryGenerationError < Error
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class QueryExecutionError < Error
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
class SanitizationError < Error
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class ApiError < Error
|
|
179
|
+
end
|
|
4
180
|
end
|