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.
@@ -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
- # Placeholder for sanitization logic
18
- # In a real scenario, this would involve more sophisticated checks,
19
- # potentially allowing only SELECT statements or using a whitelist of allowed SQL patterns.
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
- if allow_only_select && !@sanitized_sql.strip.downcase.start_with?("select")
22
- raise SanitizationError, "Query sanitization failed: Only SELECT statements are allowed by default."
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
- response = response.inspect if response.respond_to?(:inspect)
33
- llm.answer(@natural_question, @sanitized_sql, response)
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
- extract_count_if_present(result)
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
- result = model_class.find_by_sql(@sanitized_sql)
57
- return result[0].count if result[0].respond_to?(:count)
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
- if ActiveRecord::Base.connection.respond_to?(:exec_query)
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.downcase.start_with?("select")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsktiveRecord
4
- VERSION = "0.1.7"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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
- # Delegate to the Service module's implementation
38
- Service::ClassMethods.instance_method(:ask).bind(self).call(natural_language_query, options)
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 example, for OpenAI:
14
- # config.llm_api_key = ENV["OPENAI_API_KEY"]
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-3.5-turbo". Other models like "gpt-4" can be used.
20
- # config.llm_model_name = "gpt-3.5-turbo"
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
@@ -1,4 +1,180 @@
1
1
  module AsktiveRecord
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
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