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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7892f64b41650512ee4e49b6b620453f9c89e99a9e9aa20f0c3c6437b63cf1e6
4
- data.tar.gz: 657c6c0938f4179e02b76592093171b4917e0ed6cb105769139c940930ef18ec
3
+ metadata.gz: 459d4765079933a22356f0590b4921814565f2ced2dd40218bdfae032a1af89a
4
+ data.tar.gz: a75f994772cdb082159f6712a4b3f5ee6503ca3c58962f1a37743ed2531be45e
5
5
  SHA512:
6
- metadata.gz: 73e84b0e0d7ef1dfe09391869a5c3ca8a026c02c28b85efa6c0720daf28ba235ce18d8609dc5b2e5d01a3f60670211a194e179024c53f01e2a8744ffd65011fc
7
- data.tar.gz: 00db510181ccf36880333f83275e67557eec8f0e4d2f251e432e5c9f3f045ec5070eaaa480c25c45e986c6a24c7607173cb448c0d788cc783b93a0fd9ae59335
6
+ metadata.gz: d740d0ecc26e1164df04a138b535359ffd3a05604a311d329512fdf2297fc84f9e207302d3fba590a84e56721c0a8380127cfb2f84f29d61fb583aecc77778e6
7
+ data.tar.gz: 82c0fa432355cfb029c5f1656abd86e673231fe3d7bc9d7f1256a513f96ba22aed5f42a320c23a40f52691338b75a5c7fab7a9814579bf2ee7e193225c950548
data/.rubocop.yml CHANGED
@@ -1,12 +1,30 @@
1
1
  AllCops:
2
- TargetRubyVersion: 3.0
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
3
4
  Exclude:
4
5
  - 'spec/**/*'
5
6
  - 'db/schema.rb'
6
7
  - 'vendor/**/*'
8
+ - 'bin/**/*'
7
9
 
8
10
  Style/StringLiterals:
9
11
  EnforcedStyle: double_quotes
10
12
 
11
13
  Style/StringLiteralsInInterpolation:
12
14
  EnforcedStyle: double_quotes
15
+
16
+ Layout/LineLength:
17
+ Max: 120
18
+ AllowedPatterns: ['#']
19
+
20
+ Metrics/MethodLength:
21
+ Max: 20
22
+
23
+ Metrics/ClassLength:
24
+ Max: 150
25
+
26
+ Metrics/AbcSize:
27
+ Max: 25
28
+
29
+ Gemspec/DevelopmentDependencies:
30
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,4 +1,55 @@
1
- ## [Unreleased]
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-03-26
9
+
10
+ ### Added
11
+ - **Adapter Pattern**: Pluggable LLM provider architecture via `AsktiveRecord::Adapters::Base`
12
+ - Built-in `Adapters::OpenAI` adapter wrapping the ruby-openai gem
13
+ - Custom adapters can be passed via `config.adapter = MyAdapter.new(...)`
14
+ - **SQL Sanitizer** (`AsktiveRecord::SqlSanitizer`): Defense-in-depth SQL injection prevention
15
+ - Dangerous keyword blocklist (INSERT, UPDATE, DELETE, DROP, ALTER, etc.)
16
+ - Injection pattern detection (UNION SELECT, semicolons, comments, SLEEP, etc.)
17
+ - **Prompt Injection Prevention**: Input escaping and pattern filtering in `AsktiveRecord::Prompt`
18
+ - 2,000-character input limit
19
+ - Detects and rejects common prompt injection patterns
20
+ - **Configurable Logger** (`AsktiveRecord::Log`): Structured logging with `[AsktiveRecord]` prefix
21
+ - Replaces all `puts` calls with proper log levels (info, debug, warn, error)
22
+ - Defaults to `Rails.logger` when available, falls back to `Logger.new($stdout)`
23
+ - **Schema Loader Module** (`AsktiveRecord::SchemaLoader`): Shared schema loading logic
24
+ - New configuration options: `temperature`, `max_tokens`, `cache_enabled`, `adapter`, `read_only`
25
+ - Comprehensive test suite: **180 examples** (up from 87), **96.63% line / 84.88% branch coverage**
26
+ - Adapter specs (Base + OpenAI)
27
+ - SqlSanitizer spec (31 examples)
28
+ - Prompt spec (14 examples)
29
+ - Log spec (7 examples)
30
+ - SchemaLoader spec (10 examples)
31
+
32
+ ### Changed
33
+ - **BREAKING**: Minimum Ruby version raised to 3.1.0 (was 3.0.0)
34
+ - **BREAKING**: Minimum Rails version raised to 7.0 (was 6.1)
35
+ - Default LLM model changed from `gpt-3.5-turbo` to `gpt-4o-mini` (3.5 is deprecated)
36
+ - `LlmService` now delegates to adapter instead of directly using `OpenAI::Client`
37
+ - `Query` class uses `SqlSanitizer` for robust validation
38
+ - Initializer template uses `ENV["OPENAI_API_KEY"]` instead of hardcoded placeholder
39
+ - Removed `zeitwerk` dependency (not needed for gem autoloading)
40
+ - Added `rubygems_mfa_required` metadata for publish security
41
+
42
+ ### Fixed
43
+ - SQL injection vulnerability: queries were not validated before execution
44
+ - Prompt injection vulnerability: user input was passed directly to LLM prompts
45
+ - Removed `system()` shell execution call in Model module
46
+ - Fixed dead code paths in Query class (`extract_count_if_present`, no-op `exec_query`)
47
+ - API key no longer leaked in hardcoded initializer template
48
+
49
+ ### Security
50
+ - All 8 identified security vulnerabilities have been addressed
51
+ - Defense-in-depth: SQL validation at LlmService, Query, and SqlSanitizer levels
52
+ - Read-only mode enabled by default (only SELECT queries allowed)
2
53
 
3
54
  ## [0.1.0] - 2025-05-13
4
55
 
data/README.md CHANGED
@@ -1,37 +1,15 @@
1
1
  # AsktiveRecord: A Ruby gem that lets your data answer like a human
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/asktive_record.svg)](https://badge.fury.io/rb/asktive_record) <!-- Placeholder: Update once published -->
4
- [![Build Status](https://github.com/rpossan/asktive_record/actions/workflows/main.yml/badge.svg)](https://github.com/rpossan/asktive_record/actions/workflows/main.yml) <!-- Placeholder: Update with correct repo path -->
3
+ [![Gem Version](https://badge.fury.io/rb/asktive_record.svg)](https://badge.fury.io/rb/asktive_record)
4
+ [![Build Status](https://github.com/rpossan/asktive_record/actions/workflows/main.yml/badge.svg)](https://github.com/rpossan/asktive_record/actions/workflows/main.yml)
5
5
 
6
- > **AsktiveRecord** is a Ruby gem designed to bridge the gap between human language and database queries. It lets you interact with your Rails database as if you were having a conversation with a knowledgeable assistant. Instead of writing SQL or chaining ActiveRecord methods, you simply ask questions in plain English—like (or any language) "Who are my newest users?" or "What products sold the most last month?"—and get clear, human-friendly answers. AsktiveRecord translates your questions into database queries using LLM behind the scenes, so you can focus on what you want to know, not how to write the query.
6
+ > **AsktiveRecord** is a Ruby gem designed to bridge the gap between human language and database queries. It lets you interact with your Rails database as if you were having a conversation with a knowledgeable assistant. Instead of writing SQL or chaining ActiveRecord methods, you simply ask questions in plain English (or any language)—like "Who are my newest users?" or "What products sold the most last month?"—and get clear, human-friendly answers. AsktiveRecord translates your questions into database queries using LLMs behind the scenes, so you can focus on what you want to know, not how to write the query.
7
7
 
8
- ## Features
9
-
10
- * **Natural Language to SQL**: Convert human-readable questions into SQL queries.
11
- * **LLM Integration**: Currently supports OpenAI's ChatGPT, with a design that allows for future expansion to other LLMs (e.g., Gemini).
12
- * **Get Answers, Not Just Data**: Use the `.answer` method to get concise, human-readable responses to your queries, rather than raw data or SQL.
13
- * **Avoid ActiveRecord Chaining and SQL**: No need to write complex ActiveRecord queries or SQL statements. Just ask your question in natural language.
14
- * **Works with Multiple Languages**: While the gem is designed with English in mind, it can handle queries in other languages, depending on the LLM's capabilities.
15
- * **Flexible Querying Options**:
16
- * Use with specific models (e.g., `User.ask("query")`)
17
- * Use with service classes to query any table (e.g., `AskService.ask("query")`)
18
- * **Database Schema Awareness**: Uploads your database schema to the LLM for context-aware query generation.
19
- * **Developer Control**: Provides a two-step query process: first, get the LLM-generated SQL, then sanitize and execute it, giving you full control over what runs against your database.
20
- * **Smart Execution**: Automatically uses the appropriate execution method (`find_by_sql` for models, `ActiveRecord::Base.connection` for service classes).
21
- * **Easy Setup**: Simple CLI commands to install and configure the gem in your Rails project.
22
- * **Customizable Configuration**: Set your LLM provider, API keys, and model preferences through an initializer.
8
+ ## Requirements
23
9
 
24
- ## How It Works
25
-
26
- 1. **Setup**: You install the gem and run a setup command. This command can read your `db/schema.rb` (or `db/structure.sql`) and make the LLM aware of your database structure.
27
- 2. **Configuration**: You configure your LLM API key and preferences in an initializer file.
28
- 3. **Querying**: You can query your database in two ways:
29
- * Model-specific: `User.ask("your natural language query")`
30
- * Service-based (any table): `AskService.ask("your natural language query")`
31
- 4. **LLM Magic**: AsktiveRecord sends your query and the relevant schema context to the configured LLM.
32
- 5. **SQL Generation**: The LLM returns a SQL query.
33
- 6. **Safety First**: The `ask` method returns a `AsktiveRecord::Query` object containing the raw SQL. You can then inspect this SQL, apply sanitization rules (e.g., ensure it's only a `SELECT` statement), and then explicitly execute it.
34
- 7. **Execution**: The `execute` method intelligently runs the sanitized SQL. If the query originated from a model (like `User.ask`), it uses `User.find_by_sql`. If it originated from a service class (like `AskService.ask`), it uses the general `ActiveRecord::Base.connection` to execute the query, returning an array of hashes for `SELECT` statements.
10
+ - Ruby >= 3.1.0
11
+ - Rails >= 7.0 (railties)
12
+ - An OpenAI API key (or a custom LLM adapter)
35
13
 
36
14
  ## Installation
37
15
 
@@ -47,15 +25,90 @@ And then execute:
47
25
  $ bundle install
48
26
  ```
49
27
 
50
- Or install it yourself as:
28
+ ## Getting Started
29
+
30
+ Create configuration file:
51
31
 
52
32
  ```bash
53
- $ gem install asktive_record
33
+ $ bundle exec rails generate asktive_record:install
34
+ # It will create a new Rails initializer file at `config/initializers/asktive_record.rb`
54
35
  ```
55
36
 
56
- ## Getting Started
37
+ Check the `config/initializers/asktive_record.rb` file to configure your LLM provider and API key. By default, setup will generate and read the `db/schema.rb` (or `db/structure.sql`) to make the LLM aware of your database structure.
38
+
39
+ ```bash
40
+ $ bundle exec rails generate asktive_record:setup
41
+ ```
42
+
43
+ This command will generate and read the `db/schema.rb` (or `db/structure.sql`) and make the LLM aware of your database structure. You can change the schema file path and skip the dump schema setting in the `config/initializers/asktive_record.rb` file if you are using a custom schema file or a non-standard schema location for legacy databases.
44
+
45
+ See the [Configuration](#configuration) section for more details.
46
+
47
+ ```ruby
48
+ # Include AsktiveRecord in your ApplicationRecord or specific models
49
+ class User < ApplicationRecord
50
+ include AsktiveRecord
51
+ end
52
+
53
+ # Now you can query any table through this service
54
+ query = User.ask("Show me the last five users who signed up")
55
+ # => Returns a Query object with SQL targeting the users table based on your schema. Does not execute the SQL yet.
56
+
57
+ # You can check the object with the generated SQL:
58
+ query.raw_sql
59
+ # => "SELECT * FROM users ORDER BY created_at DESC LIMIT 5"
60
+
61
+ # Call the execute method to run the query on the database
62
+ results = query.execute
63
+ # => Returns an array of User objects (if the query is a SELECT) or raises an `AsktiveRecord::QueryExecutionError` if the query fails.
64
+
65
+ # If you want to execute the query and get the response like human use the method answer
66
+ results = query.answer
67
+ # => Returns a string with the answer to the question, e.g., "The last five users who signed up are: [User1, User2, User3, User4, User5]"
68
+ ```
69
+
70
+ For more detailed usage instructions, see the [Usage](#usage) section below.
71
+
72
+ ## Features
57
73
 
58
- After installing the gem, you need to run the installer to generate the configuration file:
74
+ * **Natural Language to SQL**: Convert human-readable questions into SQL queries.
75
+ * **LLM Adapter Pattern**: Pluggable architecture supporting OpenAI out of the box, with an extensible base for custom adapters (Anthropic, Gemini, local models, etc.).
76
+ * **Security First**:
77
+ * SQL injection prevention via `SqlSanitizer` (keyword blocklist + injection pattern detection)
78
+ * Prompt injection prevention with input escaping and pattern filtering
79
+ * Read-only mode by default (only SELECT queries allowed)
80
+ * Defense-in-depth validation at multiple layers
81
+ * **Get Answers, Not Just Data**: Use the `.answer` method to get concise, human-readable responses to your queries, rather than raw data or SQL.
82
+ * **Avoid ActiveRecord Chaining and SQL**: No need to write complex ActiveRecord queries or SQL statements. Just ask your question in natural language.
83
+ * **Works with Multiple Languages**: While the gem is designed with English in mind, it can handle queries in other languages, depending on the LLM's capabilities.
84
+ * **Flexible Querying Options**:
85
+ * Use with specific models (e.g., `User.ask("query")`)
86
+ * Use with service classes to query any table (e.g., `AskService.ask("query")`)
87
+ * **Database Schema Awareness**: Passes your database schema to the LLM for context-aware query generation.
88
+ * **Developer Control**: Provides a two-step query process: first, get the LLM-generated SQL, then sanitize and execute it, giving you full control over what runs against your database.
89
+ * **Smart Execution**: Automatically uses the appropriate execution method (`find_by_sql` for models, `ActiveRecord::Base.connection` for service classes).
90
+ * **Structured Logging**: Configurable logging with `[AsktiveRecord]` prefix, defaults to `Rails.logger`.
91
+ * **Easy Setup**: Simple CLI commands to install and configure the gem in your Rails project.
92
+ * **Customizable Configuration**: Set your LLM provider, API keys, model preferences, temperature, and more through an initializer.
93
+
94
+ ## How It Works
95
+
96
+ 1. **Setup**: You install the gem and run a setup command. This command reads your `db/schema.rb` (or `db/structure.sql`) and makes the LLM aware of your database structure.
97
+ 2. **Configuration**: You configure your LLM API key and preferences in an initializer file.
98
+ 3. **Querying**: You can query your database in two ways:
99
+ * Model-specific: `User.ask("your natural language query")`
100
+ * Service-based (any table): `AskService.ask("your natural language query")`
101
+ 4. **LLM Processing**: AsktiveRecord sends your query and the relevant schema context to the configured LLM via the adapter.
102
+ 5. **SQL Generation**: The LLM returns a SQL query.
103
+ 6. **Safety First**: The generated SQL is validated through multiple layers:
104
+ * `SqlSanitizer` checks for dangerous keywords and injection patterns
105
+ * Read-only mode ensures only SELECT statements execute
106
+ * The `Query` object lets you inspect the SQL before execution
107
+ 7. **Execution**: The `execute` method runs the sanitized SQL. If the query originated from a model (like `User.ask`), it uses `User.find_by_sql`. If from a service class (like `AskService.ask`), it uses `ActiveRecord::Base.connection`.
108
+
109
+ ## Configuration
110
+
111
+ After installing the gem, run the installer to generate the configuration file:
59
112
 
60
113
  ```bash
61
114
  $ bundle exec rails generate asktive_record:install
@@ -71,59 +124,95 @@ Open `config/initializers/asktive_record.rb` and configure your LLM provider and
71
124
  AsktiveRecord.configure do |config|
72
125
  # === LLM Provider ===
73
126
  # Specify the LLM provider to use. Default is :openai
74
- # Supported providers: :openai (more can be added in the future)
75
127
  # config.llm_provider = :openai
76
128
 
77
129
  # === LLM API Key ===
78
130
  # Set your API key for the chosen LLM provider.
79
131
  # It is strongly recommended to use environment variables for sensitive data.
80
- # For example, for OpenAI:
81
- # config.llm_api_key = ENV["OPENAI_API_KEY"]
82
- config.llm_api_key = "YOUR_OPENAI_API_KEY_HERE" # Replace with your actual key or ENV variable
132
+ config.llm_api_key = ENV["OPENAI_API_KEY"]
83
133
 
84
134
  # === LLM Model Name ===
85
- # Specify the model name for the LLM provider if applicable.
86
- # For OpenAI, default is "gpt-3.5-turbo". Other models like "gpt-4" can be used.
87
- # config.llm_model_name = "gpt-3.5-turbo"
135
+ # Specify the model name. Default is "gpt-4o-mini".
136
+ # Other models like "gpt-4o" or "gpt-4-turbo" can be used.
137
+ # config.llm_model_name = "gpt-4o-mini"
88
138
 
89
139
  # === Database Schema Path ===
90
- # Path to your Rails application's schema file (usually schema.rb or structure.sql).
91
- # This is used by the `asktive_record:setup` command and the `.ask` method to provide context to the LLM.
140
+ # Path to your schema file (schema.rb or structure.sql).
92
141
  # Default is "db/schema.rb".
93
142
  # config.db_schema_path = "db/schema.rb"
94
143
 
95
144
  # === Skip dump schema ===
96
- # If set to true, the schema will not be dumped when running the
97
- # `asktive_record:setup` command.
98
- # This is useful if you want to manage schema dumps manually
99
- # or if you are using a different schema management strategy.
145
+ # If true, the schema will not be dumped during `asktive_record:setup`.
100
146
  # config.skip_dump_schema = false
147
+
148
+ # === Read-Only Mode ===
149
+ # When true (default), only SELECT queries are allowed.
150
+ # config.read_only = true
151
+
152
+ # === LLM Temperature ===
153
+ # Controls randomness. Lower = more deterministic. Default: 0.2
154
+ # config.temperature = 0.2
155
+
156
+ # === LLM Max Tokens ===
157
+ # Maximum tokens in the LLM response. Default: 250
158
+ # config.max_tokens = 250
159
+
160
+ # === Custom Adapter ===
161
+ # Provide a custom LLM adapter instead of using the built-in provider.
162
+ # The adapter must inherit from AsktiveRecord::Adapters::Base.
163
+ # config.adapter = MyCustomAdapter.new(api_key: ENV["MY_LLM_KEY"])
164
+
165
+ # === Custom Logger ===
166
+ # Set a custom logger. Defaults to Rails.logger when available.
167
+ # config.logger = Logger.new($stdout)
101
168
  end
102
169
  ```
103
170
 
104
- **Important**: Securely manage your API keys. Using environment variables (e.g., `ENV["OPENAI_API_KEY"]`) is highly recommended.
171
+ **Important**: Securely manage your API keys. Using environment variables (e.g., `ENV["OPENAI_API_KEY"]`) is strongly recommended. Never commit API keys to source control.
172
+
173
+ ### Custom LLM Adapters
174
+
175
+ AsktiveRecord uses an adapter pattern for LLM communication. You can create custom adapters for any LLM provider:
176
+
177
+ ```ruby
178
+ class AnthropicAdapter < AsktiveRecord::Adapters::Base
179
+ def chat(prompt, options = {})
180
+ # Your Anthropic API call here
181
+ # Must return a string response (the SQL or answer text)
182
+ end
183
+
184
+ def default_model_name
185
+ "claude-sonnet-4-20250514"
186
+ end
187
+ end
188
+
189
+ # Use it in your configuration:
190
+ AsktiveRecord.configure do |config|
191
+ config.adapter = AnthropicAdapter.new(
192
+ api_key: ENV["ANTHROPIC_API_KEY"],
193
+ model_name: "claude-sonnet-4-20250514"
194
+ )
195
+ end
196
+ ```
105
197
 
106
198
  ### Prepare Schema for LLM
107
199
 
108
- Run the setup command to help AsktiveRecord understand your database structure. This command attempts to read your schema file (e.g., `db/schema.rb`).
200
+ Run the setup command to help AsktiveRecord understand your database structure:
109
201
 
110
202
  ```bash
111
203
  $ bundle exec rails generate asktive_record:setup
112
204
  ```
113
205
 
114
- If your app uses a custom schema file or a non-standard schema location, you can specify the path in your configuration. For example, if your schema is located at `db/custom_schema.rb`, update your initializer:
206
+ If your app uses a custom schema file or a non-standard schema location, you can specify the path in your configuration:
115
207
 
116
208
  ```ruby
117
209
  AsktiveRecord.configure do |config|
118
210
  config.db_schema_path = "db/custom_schema.rb"
119
- config.skip_dump_schema = true # If your app uses a legacy schema or doesn't need to dump it using rails db:schema:dump (default is false)
211
+ config.skip_dump_schema = true
120
212
  end
121
213
  ```
122
214
 
123
- This ensures AsktiveRecord reads the correct schema file when providing context to the LLM. Make sure the specified file accurately reflects your database structure.
124
-
125
-
126
- This step ensures that the LLM has the necessary context about your tables and columns to generate accurate SQL queries. The schema content is passed with each query to the LLM in the current version.
215
+ This ensures AsktiveRecord reads the correct schema file when providing context to the LLM.
127
216
 
128
217
  ## Usage
129
218
 
@@ -132,37 +221,35 @@ AsktiveRecord offers two ways to query your database using natural language:
132
221
  ### 1. Model-Specific Querying
133
222
 
134
223
  This approach ties queries to specific models, ideal when you know which table you want to query.
135
- If you want to apply AsktiveRecord for all your Rails models, add the `include AsktiveRecord` line in your `ApplicationRecord` or specific models. This allows you to use the `.ask` method directly on those models.
136
224
 
137
225
  ```ruby
138
- # First, include AsktiveRecord in your ApplicationRecord or specific models
226
+ # Include AsktiveRecord in your ApplicationRecord or specific models
139
227
  class ApplicationRecord < ActiveRecord::Base
140
228
  primary_abstract_class
141
229
  include AsktiveRecord
142
230
  end
143
231
 
144
232
  # Or in a specific model
145
- # In this case, you can query the User model directly for the model table. All queries will be scoped to the users table.
146
233
  class User < ApplicationRecord
147
234
  include AsktiveRecord
148
235
  end
149
236
 
150
237
  # Now you can query the User model directly
151
238
  query = User.ask("Show me the last five users who signed up")
152
- # => Returns a Query object with SQL targeting the users table, not the sql executed yet
239
+ # => Returns a Query object with SQL targeting the users table
153
240
 
154
241
  # Call the execute method to run the query on the database
155
242
  results = query.execute
156
- # => Returns an array of User objects (if the query is a SELECT) or raises an
243
+ # => Returns an array of User objects
157
244
 
158
- # If you want to execute the query and get the response like human use the method answer
245
+ # If you want a human-readable answer, use the answer method
159
246
  results = query.answer
160
- # => Returns a string with the answer to the question, e.g., "The last five users who signed up are: [User1, User2, User3, User4, User5]"
247
+ # => "The last five users who signed up are: Alice, Bob, Charlie, Diana, Eve."
161
248
  ```
162
249
 
163
250
  ### 2. Service-Class Querying (Any Table)
164
251
 
165
- This approach allows querying any table or multiple tables with joins, ideal for more complex queries or when you want a central service to handle all natural language queries.
252
+ This approach allows querying any table or multiple tables with joins:
166
253
 
167
254
  ```ruby
168
255
  # Create a service class that includes AsktiveRecord
@@ -171,23 +258,23 @@ class AskService
171
258
  # No additional code needed!
172
259
  end
173
260
 
174
- # Now you can query any table through this service
175
- asktive_record_query = AskService.ask("Which is the last user created?")
176
- # => Returns a Query object with SQL targeting the users table, not the sql executed yet
261
+ # Query any table through this service
262
+ query = AskService.ask("Which is the last user created?")
263
+ # => Returns a Query object
177
264
 
178
- asktive_record_query = AskService.ask("Which is the cheapest product?").execute
179
- # => Returns an ActiveRecord::Result object (array of hashes) with the cheapest product details
265
+ results = AskService.ask("Which is the cheapest product?").execute
266
+ # => Returns an ActiveRecord::Result object (array of hashes)
180
267
 
181
- asktive_record_query = AskService.ask("Show me products with their categories").answer
182
- # => Returns a Query object with SQL that might include JOINs between products and categories
183
- # => Returns a string with the answer to the question, e.g., "The products with their categories are: [Product1, Product2, ...]"
268
+ answer = AskService.ask("Show me products with their categories").answer
269
+ # => "The products with their categories are: Widget (Electronics), Gadget (Electronics), ..."
184
270
  ```
185
271
 
186
272
  ### Working with Query Results
187
273
 
188
- Once you have executed a query, you can work with the results. The `execute` method returns different types of results based on the context:
189
- * If the query is from a model (e.g., `User.ask(...)`), it returns an array of model instances (e.g., `User` objects).
190
- * If the query is from a service class (e.g., `AskService.ask(...)`), it returns an `ActiveRecord::Result` object, which is an array of hashes representing the query results.
274
+ The `execute` method returns different types of results based on the context:
275
+ * **Model queries** (e.g., `User.ask(...)`): returns an array of model instances (e.g., `User` objects)
276
+ * **Service queries** (e.g., `AskService.ask(...)`): returns an `ActiveRecord::Result` object (array of hashes)
277
+
191
278
  ```ruby
192
279
  # Example of working with results from a model query
193
280
  query = User.ask("Who are my newest users?")
@@ -195,21 +282,18 @@ results = query.execute
195
282
  # => results is an array of User objects
196
283
  ```
197
284
 
198
- ### The `AsktiveRecord::Query` Object
199
-
200
285
  ### The `.answer` Method
201
286
 
202
- The `.answer` method provides a human-friendly, natural language response to your query, instead of returning raw data or SQL. When you call `.answer` on a query object, AsktiveRecord executes the query and uses the LLM to generate a concise, readable answer based on the results.
287
+ The `.answer` method provides a human-friendly, natural language response to your query. When you call `.answer`, AsktiveRecord executes the query and uses the LLM to generate a concise, readable answer based on the results.
203
288
 
204
289
  ### Example Usage
205
290
 
206
-
207
291
  ```ruby
208
- # Using a service class to ask a question
292
+ # Using a service class
209
293
  response = AskService.ask("Which is the cheapest product?").answer
210
294
  # => "The cheapest product is the Earphone."
211
295
 
212
- # Using a model to ask a question
296
+ # Using a model
213
297
  response = User.ask("Who signed up most recently?").answer
214
298
  # => "The most recently signed up user is Alice Smith."
215
299
 
@@ -218,66 +302,69 @@ response = AskService.ask("How many orders were placed last week?").answer
218
302
  # => "There were 42 orders placed last week."
219
303
  ```
220
304
 
221
- Tip: You can get the query param and interpolates it into the ask method to get a more specific answer. For example, if you want to know the last user created, you can do:
305
+ Tip: You can interpolate dynamic values into the question:
222
306
 
223
307
  ```ruby
224
308
  customer = Customer.find(params[:id])
225
- query = "Which is my most sold product?"
226
- response = AskService.ask("For the customer #{customer.id}, #{query}").answer
309
+ response = AskService.ask("For customer #{customer.id}, which is the most sold product?").answer
227
310
  # => "The most sold product for customer ABC is the Premium Widget."
228
311
  ```
229
312
 
230
- The `.answer` method is ideal when you want a direct, human-readable summary, rather than an array of records or a SQL query.
313
+ ### Query Object API
231
314
 
232
- The `ask()` method returns an instance of `AsktiveRecord::Query`. This object has a few useful methods:
315
+ The `ask()` method returns an instance of `AsktiveRecord::Query`. Key methods:
233
316
 
234
- * `raw_sql`: The raw SQL string generated by the LLM.
235
- * `sanitized_sql`: The SQL string after `sanitize!` has been called. Initially, it's the same as `raw_sql`.
236
- * `sanitize!(allow_only_select: true)`: Performs sanitization. By default, it ensures the query is a `SELECT` statement. Raises `AsktiveRecord::SanitizationError` on failure. Returns `self` for chaining.
237
- * `execute`: Executes the `sanitized_sql` against the database.
238
- * If the query originated from a model (e.g., `User.ask(...)`), it uses `YourModel.find_by_sql` and returns model instances.
239
- * If the query originated from a service class (e.g., `AskService.ask(...)`), it uses `ActiveRecord::Base.connection.select_all` (for SELECT) or `execute` and returns an `ActiveRecord::Result` object (array of hashes) or connection-specific results.
240
- * `to_s`: Returns the `sanitized_sql` (or `raw_sql` if `sanitized_sql` hasn't been modified from raw).
317
+ * `raw_sql` The raw SQL string generated by the LLM.
318
+ * `sanitized_sql` The SQL string after `sanitize!` has been called.
319
+ * `sanitize!(allow_only_select: true)` Validates the query through `SqlSanitizer`. Raises `AsktiveRecord::SanitizationError` on failure.
320
+ * `execute` Executes the sanitized SQL against the database.
321
+ * `answer` Executes the query and returns a human-readable LLM-generated answer.
322
+ * `to_s` Returns the sanitized SQL (or raw SQL if not yet sanitized).
241
323
 
242
324
  ## Logging
243
- AsktiveRecord provides logging to help you debug and monitor natural language queries, generated SQL, and results. By default, logs are sent to the Rails logger at the `:info` level.
244
325
 
245
- ### Example Log Output
326
+ AsktiveRecord provides structured logging to help you debug and monitor queries. By default, logs are sent to `Rails.logger` with the `[AsktiveRecord]` prefix.
246
327
 
247
- When you run a query, you might see logs like:
328
+ ### Example Log Output
248
329
 
249
330
  ```
250
331
  [AsktiveRecord] Received question: "Who are my newest users?"
251
- [AsktiveRecord] Generated SQL: SELECT * FROM users ORDER BY created_at DESC LIMIT 5;
252
- [AsktiveRecord] Sanitized SQL: SELECT * FROM users ORDER BY created_at DESC LIMIT 5;
253
- [AsktiveRecord] Executing SQL via User.find_by_sql
254
- [AsktiveRecord] Query results: [#<User id: 1, name: "Alice", ...>, ...]
332
+ [AsktiveRecord] Generated SQL: SELECT * FROM users ORDER BY created_at DESC LIMIT 5
333
+ [AsktiveRecord] Sanitized SQL: SELECT * FROM users ORDER BY created_at DESC LIMIT 5
255
334
  ```
256
335
 
257
- When using the `.answer` method:
336
+ When using `.answer`:
258
337
 
259
338
  ```
260
339
  [AsktiveRecord] Received question: "How many orders were placed last week?"
261
- [AsktiveRecord] Generated SQL: SELECT COUNT(*) FROM orders WHERE created_at >= '2024-06-01' AND created_at < '2024-06-08';
262
- [AsktiveRecord] Query results: [{"count"=>42}]
263
- [AsktiveRecord] LLM answer: "There were 42 orders placed last week."
340
+ [AsktiveRecord] Generated SQL: SELECT COUNT(*) FROM orders WHERE created_at >= '2024-06-01'
341
+ [AsktiveRecord] Answering question: How many orders were placed last week?
264
342
  ```
265
343
 
344
+ ## Security
345
+
346
+ AsktiveRecord implements defense-in-depth security:
347
+
348
+ * **SQL Sanitization**: All generated SQL passes through `SqlSanitizer` which blocks dangerous keywords (INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, etc.) and injection patterns (UNION SELECT, semicolons, comments, SLEEP, etc.)
349
+ * **Read-Only Mode**: Enabled by default — only SELECT queries are allowed to execute
350
+ * **Prompt Injection Prevention**: User input is escaped and filtered before being sent to the LLM, with a 2,000-character limit
351
+ * **No Hardcoded Secrets**: Initializer template uses `ENV["OPENAI_API_KEY"]` by default
352
+ * **Multi-Layer Validation**: SQL is validated at the LLM response level, the Query level, and the SqlSanitizer level
266
353
 
267
354
  ## Supported LLMs
268
355
 
269
- * **Currently**: OpenAI (ChatGPT models like `gpt-3.5-turbo`, `gpt-4`).
270
- * **Future**: The gem is designed to be extensible. Support for other LLMs (like Google's Gemini) can be added by creating new LLM service adapters.
356
+ * **Built-in**: OpenAI (models like `gpt-4o-mini`, `gpt-4o`, `gpt-4-turbo`)
357
+ * **Custom Adapters**: Any LLM can be supported by creating an adapter that inherits from `AsktiveRecord::Adapters::Base` and implements the `#chat` method
271
358
 
272
359
  ## Contributing
273
360
 
274
361
  Contributions are welcome! Whether it's bug reports, feature requests, documentation improvements, or code contributions, please feel free to open an issue or submit a pull request on GitHub.
275
362
 
276
- 1. Fork the repository ([https://github.com/rpossan/asktive_record/fork](https://github.com/rpossan/asktive_record/fork)).
277
- 2. Create your feature branch (`git checkout -b my-new-feature`).
278
- 3. Commit your changes (`git commit -am 'Add some feature'`).
279
- 4. Push to the branch (`git push origin my-new-feature`).
280
- 5. Create a new Pull Request.
363
+ 1. Fork the repository ([https://github.com/rpossan/asktive_record/fork](https://github.com/rpossan/asktive_record/fork)).
364
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
365
+ 3. Commit your changes (`git commit -am 'Add some feature'`).
366
+ 4. Push to the branch (`git push origin my-new-feature`).
367
+ 5. Create a new Pull Request.
281
368
 
282
369
  Please make sure to add tests for your changes and ensure all tests pass (`bundle exec rspec`). Also, adhere to the existing code style (you can use RuboCop: `bundle exec rubocop`).
283
370
 
@@ -294,8 +381,3 @@ The gem is available as open source under the terms of the [MIT License](https:/
294
381
  ## Code of Conduct
295
382
 
296
383
  Everyone interacting in the AsktiveRecord project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
297
-
298
- ---
299
-
300
- *This gem was proudly developed with the assistance of an AI agent.* Author: [rpossan](https://github.com/rpossan)
301
-
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsktiveRecord
4
+ module Adapters
5
+ # Base adapter class that defines the interface all LLM adapters must implement.
6
+ # To create a custom adapter, inherit from this class and implement the required methods.
7
+ #
8
+ # @example Creating a custom adapter
9
+ # class MyAdapter < AsktiveRecord::Adapters::Base
10
+ # def chat(prompt, options = {})
11
+ # # Your LLM API call here
12
+ # # Must return a string response
13
+ # end
14
+ # end
15
+ #
16
+ # AsktiveRecord.configure do |config|
17
+ # config.adapter = MyAdapter.new(api_key: ENV["MY_LLM_KEY"])
18
+ # end
19
+ class Base
20
+ attr_reader :api_key, :model_name
21
+
22
+ def initialize(api_key:, model_name: nil)
23
+ @api_key = api_key
24
+ @model_name = model_name
25
+
26
+ return if @api_key
27
+
28
+ raise ConfigurationError,
29
+ "LLM API key is required for adapter initialization."
30
+ end
31
+
32
+ # Send a prompt to the LLM and return the text response.
33
+ #
34
+ # @param prompt [String] the prompt to send
35
+ # @param options [Hash] additional options (temperature, max_tokens, etc.)
36
+ # @return [String, nil] the text response from the LLM
37
+ def chat(prompt, options = {})
38
+ raise NotImplementedError, "#{self.class.name} must implement #chat"
39
+ end
40
+
41
+ # Returns the default model name for this adapter.
42
+ #
43
+ # @return [String] the default model name
44
+ def default_model_name
45
+ raise NotImplementedError, "#{self.class.name} must implement #default_model_name"
46
+ end
47
+
48
+ # Returns the resolved model name (configured or default).
49
+ #
50
+ # @return [String]
51
+ def resolved_model_name
52
+ model_name || default_model_name
53
+ end
54
+ end
55
+ end
56
+ end