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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 459d4765079933a22356f0590b4921814565f2ced2dd40218bdfae032a1af89a
|
|
4
|
+
data.tar.gz: a75f994772cdb082159f6712a4b3f5ee6503ca3c58962f1a37743ed2531be45e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d740d0ecc26e1164df04a138b535359ffd3a05604a311d329512fdf2297fc84f9e207302d3fba590a84e56721c0a8380127cfb2f84f29d61fb583aecc77778e6
|
|
7
|
+
data.tar.gz: 82c0fa432355cfb029c5f1656abd86e673231fe3d7bc9d7f1256a513f96ba22aed5f42a320c23a40f52691338b75a5c7fab7a9814579bf2ee7e193225c950548
|
data/.rubocop.yml
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
AllCops:
|
|
2
|
-
TargetRubyVersion: 3.
|
|
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
|
-
|
|
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
|
-
[](https://badge.fury.io/rb/asktive_record)
|
|
4
|
-
[](https://github.com/rpossan/asktive_record/actions/workflows/main.yml)
|
|
3
|
+
[](https://badge.fury.io/rb/asktive_record)
|
|
4
|
+
[](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
|
|
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
|
-
##
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
28
|
+
## Getting Started
|
|
29
|
+
|
|
30
|
+
Create configuration file:
|
|
51
31
|
|
|
52
32
|
```bash
|
|
53
|
-
$
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
#
|
|
87
|
-
# config.llm_model_name = "gpt-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
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
|
|
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
|
|
243
|
+
# => Returns an array of User objects
|
|
157
244
|
|
|
158
|
-
# If you want
|
|
245
|
+
# If you want a human-readable answer, use the answer method
|
|
159
246
|
results = query.answer
|
|
160
|
-
# =>
|
|
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
|
|
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
|
-
#
|
|
175
|
-
|
|
176
|
-
# => Returns a Query object
|
|
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
|
-
|
|
179
|
-
# => Returns an ActiveRecord::Result object (array of hashes)
|
|
265
|
+
results = AskService.ask("Which is the cheapest product?").execute
|
|
266
|
+
# => Returns an ActiveRecord::Result object (array of hashes)
|
|
180
267
|
|
|
181
|
-
|
|
182
|
-
# =>
|
|
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
|
-
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
305
|
+
Tip: You can interpolate dynamic values into the question:
|
|
222
306
|
|
|
223
307
|
```ruby
|
|
224
308
|
customer = Customer.find(params[:id])
|
|
225
|
-
|
|
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
|
-
|
|
313
|
+
### Query Object API
|
|
231
314
|
|
|
232
|
-
The `ask()` method returns an instance of `AsktiveRecord::Query`.
|
|
315
|
+
The `ask()` method returns an instance of `AsktiveRecord::Query`. Key methods:
|
|
233
316
|
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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'
|
|
262
|
-
[AsktiveRecord]
|
|
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
|
-
*
|
|
270
|
-
*
|
|
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.
|
|
277
|
-
2.
|
|
278
|
-
3.
|
|
279
|
-
4.
|
|
280
|
-
5.
|
|
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
|