sql-chatbot-rails 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +20 -0
- data/app/controllers/sql_chatbot/chatbot_controller.rb +158 -0
- data/config/routes.rb +11 -0
- data/lib/generators/sql_chatbot/install_generator.rb +25 -0
- data/lib/generators/sql_chatbot/templates/initializer.rb +22 -0
- data/lib/sql_chatbot/auth/cors.rb +35 -0
- data/lib/sql_chatbot/auth/jwt.rb +34 -0
- data/lib/sql_chatbot/configuration.rb +58 -0
- data/lib/sql_chatbot/engine.rb +23 -0
- data/lib/sql_chatbot/grammar/count_renderer.rb +113 -0
- data/lib/sql_chatbot/grammar/entity_candidates.rb +210 -0
- data/lib/sql_chatbot/grammar/intent_extractor.rb +191 -0
- data/lib/sql_chatbot/grammar/list_renderer.rb +50 -0
- data/lib/sql_chatbot/grammar/miss_logger.rb +17 -0
- data/lib/sql_chatbot/grammar/modifiers.rb +145 -0
- data/lib/sql_chatbot/grammar/primitives.rb +69 -0
- data/lib/sql_chatbot/grammar/programmatic_renderer.rb +258 -0
- data/lib/sql_chatbot/grammar/registry.rb +66 -0
- data/lib/sql_chatbot/grammar/sanity_check.rb +37 -0
- data/lib/sql_chatbot/grammar/template_compiler.rb +179 -0
- data/lib/sql_chatbot/llm/client.rb +87 -0
- data/lib/sql_chatbot/prompts/answer.rb +157 -0
- data/lib/sql_chatbot/prompts/classify.rb +59 -0
- data/lib/sql_chatbot/prompts/generate_sql.rb +88 -0
- data/lib/sql_chatbot/services/code_indexer.rb +337 -0
- data/lib/sql_chatbot/services/grammar_pipeline.rb +45 -0
- data/lib/sql_chatbot/services/model_introspector.rb +152 -0
- data/lib/sql_chatbot/services/orchestrator.rb +635 -0
- data/lib/sql_chatbot/services/registry_builder.rb +385 -0
- data/lib/sql_chatbot/services/route_introspector.rb +118 -0
- data/lib/sql_chatbot/services/schema_service.rb +884 -0
- data/lib/sql_chatbot/services/sql_executor.rb +81 -0
- data/lib/sql_chatbot/version.rb +5 -0
- data/lib/sql_chatbot_rails.rb +91 -0
- data/vendor/assets/widget.js +53 -0
- metadata +180 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7ff5a3ddeee3311ba443adb0cde9a935e63ec9781271e2677ab368e653765e5b
|
|
4
|
+
data.tar.gz: '072728620fd9e8331e874a47c1f61ef6eb764e47b2bd7a8dcc75fdae7ef9d258'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 11596701b71c9c481305ee51ad60571f41d6f4734750a280d5aeb1885545b8f92455788d5c2522470f73ef43964d250c0e3727ec2929d8ac19129d9da63c4422
|
|
7
|
+
data.tar.gz: c4755ceeae1e1e87647f2ddb2a7bd5eb3faeab1bfde1c9a8d18a71b5efff9f5cb5a3f5ded8743eebbe373dc766e4d2a39548513040d0393f340dfe6aa8dfa895
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bhumit Patel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# sql-chatbot-rails
|
|
2
|
+
|
|
3
|
+
AI chatbot for any Rails app — auto-discovers schema, indexes code, executes SQL, streams answers via chat widget.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'sql-chatbot-rails'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
rails generate sql_chatbot:install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
See the generated `config/initializers/sql_chatbot.rb` for configuration options.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlChatbot
|
|
4
|
+
class ChatbotController < ActionController::Base
|
|
5
|
+
include ActionController::Live
|
|
6
|
+
skip_forgery_protection
|
|
7
|
+
before_action :handle_cors
|
|
8
|
+
|
|
9
|
+
def widget
|
|
10
|
+
if SqlChatbot.config&.secret
|
|
11
|
+
cookies[:chatbot_token] = {
|
|
12
|
+
value: SqlChatbot.config.secret,
|
|
13
|
+
httponly: true,
|
|
14
|
+
same_site: :strict,
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
widget_path = File.join(SqlChatbot::Engine.root, "vendor", "assets", "widget.js")
|
|
18
|
+
render body: File.read(widget_path), content_type: "application/javascript"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def health
|
|
22
|
+
ensure_initialized!
|
|
23
|
+
render json: {
|
|
24
|
+
status: "ok",
|
|
25
|
+
tables: SqlChatbot.schema_service.table_count,
|
|
26
|
+
codeFiles: SqlChatbot.code_indexer.file_count,
|
|
27
|
+
}
|
|
28
|
+
rescue => e
|
|
29
|
+
render json: { status: "error", message: e.message }, status: 500
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ask
|
|
33
|
+
return render_unauthorized unless authorized?
|
|
34
|
+
ensure_initialized!
|
|
35
|
+
|
|
36
|
+
question = params[:question]
|
|
37
|
+
return render json: { error: "question is required" }, status: 400 if question.blank?
|
|
38
|
+
|
|
39
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
40
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
41
|
+
response.headers["Connection"] = "keep-alive"
|
|
42
|
+
|
|
43
|
+
SqlChatbot.orchestrator.handle_question(
|
|
44
|
+
question: question,
|
|
45
|
+
page_context: params[:pageContext],
|
|
46
|
+
history: params[:history],
|
|
47
|
+
).each do |event|
|
|
48
|
+
response.stream.write("data: #{event.to_json}\n\n")
|
|
49
|
+
end
|
|
50
|
+
rescue => e
|
|
51
|
+
unless response.stream.closed?
|
|
52
|
+
response.stream.write("data: #{({ type: "error", message: e.message }).to_json}\n\n")
|
|
53
|
+
end
|
|
54
|
+
ensure
|
|
55
|
+
response.stream.close
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def refresh
|
|
59
|
+
return render_unauthorized unless authorized?
|
|
60
|
+
ensure_initialized!
|
|
61
|
+
SqlChatbot.schema_service.discover
|
|
62
|
+
SqlChatbot.code_indexer.index(SqlChatbot.config.code_paths)
|
|
63
|
+
render json: { status: "refreshed" }
|
|
64
|
+
rescue => e
|
|
65
|
+
render json: { status: "error", message: e.message }, status: 500
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def receive_manifest
|
|
69
|
+
return render_unauthorized unless authorized?
|
|
70
|
+
ensure_initialized!
|
|
71
|
+
|
|
72
|
+
manifest = params[:manifest]
|
|
73
|
+
if manifest.present?
|
|
74
|
+
manifest_data = manifest.respond_to?(:to_unsafe_h) ? manifest.to_unsafe_h : manifest.to_h
|
|
75
|
+
SqlChatbot.orchestrator.set_manifest(manifest_data)
|
|
76
|
+
render json: { status: "received", routeCount: manifest["routes"]&.length || 0 }
|
|
77
|
+
else
|
|
78
|
+
render json: { error: "manifest is required" }, status: 400
|
|
79
|
+
end
|
|
80
|
+
rescue => e
|
|
81
|
+
render json: { status: "error", message: e.message }, status: 500
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def create_session
|
|
85
|
+
origin = request.headers["Origin"]
|
|
86
|
+
|
|
87
|
+
# Validate origin
|
|
88
|
+
allowed_origins = SqlChatbot.config&.allowed_origins
|
|
89
|
+
if origin && !Auth::Cors.origin_allowed?(origin, allowed_origins)
|
|
90
|
+
return render json: { error: "Origin not allowed" }, status: 403
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Check auth
|
|
94
|
+
unless authorized?
|
|
95
|
+
return render_unauthorized
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
config = SqlChatbot.config
|
|
99
|
+
token = Auth::Jwt.generate_token(
|
|
100
|
+
secret: config.resolved_token_secret,
|
|
101
|
+
origin: origin,
|
|
102
|
+
lifetime_seconds: config.token_lifetime
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
render json: { token: token, expires_in: config.token_lifetime }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def preflight
|
|
109
|
+
head :no_content
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def authorized?
|
|
115
|
+
return true unless SqlChatbot.config&.secret
|
|
116
|
+
|
|
117
|
+
auth_header = request.headers["Authorization"]
|
|
118
|
+
if auth_header
|
|
119
|
+
scheme, token = auth_header.split(" ", 2)
|
|
120
|
+
if scheme == "Bearer" && token
|
|
121
|
+
# Try JWT verification first
|
|
122
|
+
begin
|
|
123
|
+
Auth::Jwt.verify_token(token: token, secret: SqlChatbot.config.resolved_token_secret)
|
|
124
|
+
return true
|
|
125
|
+
rescue Auth::Jwt::TokenExpired, Auth::Jwt::TokenInvalid
|
|
126
|
+
# Not a JWT, try secret match
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Try secret match (existing behavior)
|
|
130
|
+
return true if token == SqlChatbot.config.secret
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check cookie (existing behavior)
|
|
135
|
+
return true if cookies[:chatbot_token] == SqlChatbot.config.secret
|
|
136
|
+
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def render_unauthorized
|
|
141
|
+
render json: { error: "Unauthorized" }, status: 401
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def handle_cors
|
|
145
|
+
origin = request.headers["Origin"]
|
|
146
|
+
return unless origin
|
|
147
|
+
|
|
148
|
+
allowed_origins = SqlChatbot.config&.allowed_origins
|
|
149
|
+
if Auth::Cors.origin_allowed?(origin, allowed_origins)
|
|
150
|
+
Auth::Cors.set_headers(response, origin)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ensure_initialized!
|
|
155
|
+
SqlChatbot.ensure_initialized!
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
SqlChatbot::Engine.routes.draw do
|
|
4
|
+
get "widget.js", to: "chatbot#widget"
|
|
5
|
+
get "api/health", to: "chatbot#health"
|
|
6
|
+
post "api/ask", to: "chatbot#ask"
|
|
7
|
+
post "api/refresh", to: "chatbot#refresh"
|
|
8
|
+
post "api/session", to: "chatbot#create_session"
|
|
9
|
+
post "api/manifest", to: "chatbot#receive_manifest"
|
|
10
|
+
match "api/*path", to: "chatbot#preflight", via: :options
|
|
11
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlChatbot
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
def copy_initializer
|
|
9
|
+
template "initializer.rb", "config/initializers/sql_chatbot.rb"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add_route
|
|
13
|
+
route 'mount SqlChatbot::Engine, at: "/chatbot"'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show_instructions
|
|
17
|
+
say ""
|
|
18
|
+
say "SQL Chatbot installed!", :green
|
|
19
|
+
say "1. Edit config/initializers/sql_chatbot.rb with your API key"
|
|
20
|
+
say '2. Add to your layout: <script src="/chatbot/widget.js"></script>'
|
|
21
|
+
say ""
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
SqlChatbot.configure do |c|
|
|
2
|
+
# LLM provider: "openai" (default), "groq", "ollama"
|
|
3
|
+
c.llm_provider = "openai"
|
|
4
|
+
c.llm_api_key = ENV["OPENAI_API_KEY"]
|
|
5
|
+
|
|
6
|
+
# Optional: override model or base URL
|
|
7
|
+
# c.llm_model = "gpt-4o-mini"
|
|
8
|
+
# c.llm_base_url = "https://api.openai.com/v1"
|
|
9
|
+
|
|
10
|
+
# Optional: restrict chatbot access (Bearer token or cookie)
|
|
11
|
+
# c.secret = ENV["CHATBOT_SECRET"]
|
|
12
|
+
|
|
13
|
+
# Optional: domain-specific context for better SQL generation
|
|
14
|
+
# c.custom_context = "status=3 means Deleted, always exclude deleted records"
|
|
15
|
+
|
|
16
|
+
# Cross-origin support (for distributed frontend/backend setups):
|
|
17
|
+
# c.allowed_origins = ["https://your-frontend-domain.com"]
|
|
18
|
+
# c.token_lifetime = 900 # JWT lifetime in seconds (default: 15 minutes)
|
|
19
|
+
|
|
20
|
+
# Code paths to index (default: ["./app"])
|
|
21
|
+
# c.code_paths = ["./app", "./lib"]
|
|
22
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlChatbot
|
|
4
|
+
module Auth
|
|
5
|
+
module Cors
|
|
6
|
+
ALLOWED_METHODS = "GET, POST, OPTIONS"
|
|
7
|
+
ALLOWED_HEADERS = "Authorization, Content-Type"
|
|
8
|
+
MAX_AGE = "86400"
|
|
9
|
+
|
|
10
|
+
def self.origin_allowed?(origin, allowed_origins)
|
|
11
|
+
return false if origin.nil?
|
|
12
|
+
|
|
13
|
+
if allowed_origins.is_a?(Array) && allowed_origins.any?
|
|
14
|
+
return allowed_origins.include?(origin)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# No allowlist: allow localhost in development/test only
|
|
18
|
+
if Rails.env.development? || Rails.env.test?
|
|
19
|
+
return origin.match?(/\Ahttps?:\/\/localhost(:\d+)?\z/)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.set_headers(response, origin)
|
|
26
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
27
|
+
response.headers["Access-Control-Allow-Methods"] = ALLOWED_METHODS
|
|
28
|
+
response.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS
|
|
29
|
+
response.headers["Access-Control-Max-Age"] = MAX_AGE
|
|
30
|
+
existing_vary = response.headers["Vary"]
|
|
31
|
+
response.headers["Vary"] = existing_vary ? "#{existing_vary}, Origin" : "Origin"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module SqlChatbot
|
|
6
|
+
module Auth
|
|
7
|
+
module Jwt
|
|
8
|
+
class TokenExpired < StandardError; end
|
|
9
|
+
class TokenInvalid < StandardError; end
|
|
10
|
+
|
|
11
|
+
ALGORITHM = "HS256"
|
|
12
|
+
DEFAULT_LIFETIME = 900 # 15 minutes
|
|
13
|
+
|
|
14
|
+
def self.generate_token(secret:, sub: nil, origin: nil, lifetime_seconds: DEFAULT_LIFETIME)
|
|
15
|
+
now = Time.now.to_i
|
|
16
|
+
payload = {
|
|
17
|
+
"iat" => now,
|
|
18
|
+
"exp" => now + lifetime_seconds
|
|
19
|
+
}
|
|
20
|
+
payload["sub"] = sub if sub
|
|
21
|
+
payload["origin"] = origin if origin
|
|
22
|
+
::JWT.encode(payload, secret, ALGORITHM)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.verify_token(token:, secret:)
|
|
26
|
+
::JWT.decode(token, secret, true, algorithm: ALGORITHM).first
|
|
27
|
+
rescue ::JWT::ExpiredSignature
|
|
28
|
+
raise TokenExpired, "Token expired"
|
|
29
|
+
rescue ::JWT::DecodeError
|
|
30
|
+
raise TokenInvalid, "Invalid token"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlChatbot
|
|
4
|
+
class Configuration
|
|
5
|
+
PROVIDER_PRESETS = {
|
|
6
|
+
"openai" => { base_url: "https://api.openai.com/v1", model: "gpt-4o-mini" },
|
|
7
|
+
"groq" => { base_url: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile" },
|
|
8
|
+
"ollama" => { base_url: "http://localhost:11434/v1", model: "llama3.1:8b" },
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor :llm_api_key, :llm_provider, :llm_model, :llm_base_url,
|
|
12
|
+
:secret, :code_paths, :custom_context,
|
|
13
|
+
:allowed_origins, # Array of allowed cross-origin domains
|
|
14
|
+
:token_lifetime, # JWT lifetime in seconds (default: 900)
|
|
15
|
+
:token_secret, # JWT signing secret (auto-generated if nil)
|
|
16
|
+
:grammar_enabled, # Boolean — enable grammar-first SQL path (default: true)
|
|
17
|
+
:grammar_confidence_threshold, # Float — minimum confidence for grammar hit (default: 0.7)
|
|
18
|
+
:grammar_miss_log_path, # String — path to NDJSON miss log (default: nil, resolved at runtime)
|
|
19
|
+
:default_filters, # Hash — app-specific row-level conventions injected into every grammar SELECT.
|
|
20
|
+
# Keys: "table.column" or "*.column"; values: SQL fragment.
|
|
21
|
+
# Example: { "*.status" => "!= 3" } for MSP "deleted = status 3" convention.
|
|
22
|
+
:aliases # Hash — custom user-word -> entity-name mappings overriding auto-detection.
|
|
23
|
+
# Example: { "customer" => "account_user" } when Saleor's "customers"
|
|
24
|
+
# live in account_user. Always wins over auto-detected aliases on conflict.
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@llm_provider = "openai"
|
|
28
|
+
@code_paths = ["./app"]
|
|
29
|
+
@token_lifetime = 900
|
|
30
|
+
@_resolved_token_secret = nil
|
|
31
|
+
@grammar_enabled = true
|
|
32
|
+
@grammar_confidence_threshold = 0.7
|
|
33
|
+
@grammar_miss_log_path = nil
|
|
34
|
+
@default_filters = {}
|
|
35
|
+
@aliases = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resolved_token_secret
|
|
39
|
+
@_resolved_token_secret ||= (@token_secret || ENV["CHATBOT_TOKEN_SECRET"] || SecureRandom.hex(32))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolved_base_url
|
|
43
|
+
llm_base_url || PROVIDER_PRESETS.dig(llm_provider, :base_url) || PROVIDER_PRESETS["openai"][:base_url]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolved_model
|
|
47
|
+
llm_model || PROVIDER_PRESETS.dig(llm_provider, :model) || PROVIDER_PRESETS["openai"][:model]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolved_api_key
|
|
51
|
+
llm_api_key ||
|
|
52
|
+
ENV["LLM_API_KEY"] ||
|
|
53
|
+
ENV["OPENAI_API_KEY"] ||
|
|
54
|
+
ENV["GROQ_API_KEY"] ||
|
|
55
|
+
(llm_provider == "ollama" ? "ollama" : nil)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlChatbot
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace SqlChatbot
|
|
6
|
+
|
|
7
|
+
initializer "sql_chatbot.build_registry", after: :eager_load! do |_app|
|
|
8
|
+
begin
|
|
9
|
+
require "sql_chatbot/services/registry_builder"
|
|
10
|
+
cfg = SqlChatbot.configuration
|
|
11
|
+
SqlChatbot.registry = SqlChatbot::Services::RegistryBuilder
|
|
12
|
+
.new(
|
|
13
|
+
default_filters: cfg&.default_filters,
|
|
14
|
+
custom_aliases: cfg&.aliases,
|
|
15
|
+
)
|
|
16
|
+
.build
|
|
17
|
+
rescue => e
|
|
18
|
+
Rails.logger&.warn("[sql-chatbot] registry_build_failed: #{e.message}")
|
|
19
|
+
SqlChatbot.registry = nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlChatbot
|
|
4
|
+
module Grammar
|
|
5
|
+
# Programmatic renderer for the grammar's COUNT primitive. Bypasses the
|
|
6
|
+
# answer-stream LLM entirely — a single number is too easy for the LLM to
|
|
7
|
+
# mis-narrate (e.g., rendering `count = 0` as "No matching records found"
|
|
8
|
+
# instead of "There are 0 X").
|
|
9
|
+
#
|
|
10
|
+
# Mirrors the architectural shape of `ListRenderer`:
|
|
11
|
+
# - Pure function. No side effects, no LLM calls.
|
|
12
|
+
# - Returns { ok: true, text: "..." } when conditions match, else { ok: false }.
|
|
13
|
+
# - Caller (Orchestrator) yields the text as `token` events when ok.
|
|
14
|
+
#
|
|
15
|
+
# Conditions for programmatic render:
|
|
16
|
+
# - primitive == :COUNT (or "COUNT")
|
|
17
|
+
# - exactly one result row
|
|
18
|
+
# - that row has a numeric (or numeric-stringable) `count` field — the
|
|
19
|
+
# standard column shape PostgreSQL emits for `SELECT COUNT(*)`.
|
|
20
|
+
# Anything else (grouped counts, multi-row results) falls through to the
|
|
21
|
+
# answer LLM.
|
|
22
|
+
module CountRenderer
|
|
23
|
+
def self.try_render(primitive, entity_display_label, rows)
|
|
24
|
+
return { ok: false } unless primitive.to_s == "COUNT"
|
|
25
|
+
return { ok: false } unless rows.is_a?(Array) && rows.length == 1
|
|
26
|
+
|
|
27
|
+
row = rows.first
|
|
28
|
+
return { ok: false } unless row.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
v = row["count"] || row[:count]
|
|
31
|
+
n = as_number(v)
|
|
32
|
+
return { ok: false } if n.nil?
|
|
33
|
+
|
|
34
|
+
label = entity_display_label.to_s.empty? ? "Item" : entity_display_label.to_s
|
|
35
|
+
noun = n == 1 ? to_singular_label(label) : to_plural_label(label)
|
|
36
|
+
verb = n == 1 ? "is" : "are"
|
|
37
|
+
{ ok: true, text: "There #{verb} #{n} #{noun}." }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.as_number(v)
|
|
41
|
+
case v
|
|
42
|
+
when Integer then v
|
|
43
|
+
when Numeric then v.to_i
|
|
44
|
+
when String then (v =~ /\A-?\d+\z/) ? v.to_i : nil
|
|
45
|
+
else nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Force the LAST word of a (possibly multi-word) label to plural.
|
|
50
|
+
# Detects already-plural inputs ("Credentials" → singularize gives
|
|
51
|
+
# "credential" — different — so it's already plural) to avoid the
|
|
52
|
+
# "Credentialses" double-pluralization.
|
|
53
|
+
def self.to_plural_label(label)
|
|
54
|
+
transform_last_word(label) { |w| already_plural?(w) ? w : pluralize_word(w) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Force the LAST word to singular. Mirror of `to_plural_label`.
|
|
58
|
+
def self.to_singular_label(label)
|
|
59
|
+
transform_last_word(label) { |w| already_plural?(w) ? singularize_word(w) : w }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.already_plural?(word)
|
|
63
|
+
# A word is already plural if singularize produces a different word.
|
|
64
|
+
# "credentials" -> "credential" (different) -> plural ✓
|
|
65
|
+
# "class" -> "class" (ss-guard, unchanged) -> not plural ✓
|
|
66
|
+
# "inbox" -> "inbox" (no s ending) -> not plural ✓
|
|
67
|
+
singularize_word(word) != word
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.transform_last_word(label)
|
|
71
|
+
parts = label.split(" ")
|
|
72
|
+
last = parts.last
|
|
73
|
+
return label if last.nil? || last.empty?
|
|
74
|
+
lower = last.downcase
|
|
75
|
+
transformed = yield(lower)
|
|
76
|
+
parts[-1] = match_case(last, transformed)
|
|
77
|
+
parts.join(" ")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.match_case(original, transformed)
|
|
81
|
+
return transformed if original.empty? || transformed.empty?
|
|
82
|
+
if original[0] == original[0].upcase
|
|
83
|
+
transformed[0].upcase + transformed[1..]
|
|
84
|
+
else
|
|
85
|
+
transformed
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.pluralize_word(word)
|
|
90
|
+
if word =~ /(s|x|z|ch|sh)\z/i then word + "es"
|
|
91
|
+
elsif word =~ /[^aeiouAEIOU]y\z/ then word[0..-2] + "ies"
|
|
92
|
+
else word + "s"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
IRREGULAR_PLURALS = {
|
|
97
|
+
"people" => "person", "men" => "man", "women" => "woman", "children" => "child",
|
|
98
|
+
"feet" => "foot", "teeth" => "tooth", "geese" => "goose", "mice" => "mouse",
|
|
99
|
+
"analyses" => "analysis", "bases" => "basis", "crises" => "crisis", "theses" => "thesis",
|
|
100
|
+
"data" => "datum", "criteria" => "criterion", "phenomena" => "phenomenon",
|
|
101
|
+
}.freeze
|
|
102
|
+
|
|
103
|
+
def self.singularize_word(word)
|
|
104
|
+
return word if word.empty?
|
|
105
|
+
return IRREGULAR_PLURALS[word] if IRREGULAR_PLURALS.key?(word)
|
|
106
|
+
return word[0..-4] + "y" if word.length > 3 && word.end_with?("ies")
|
|
107
|
+
return word[0..-3] if word =~ /(sses|xes|zes|ches|shes)\z/
|
|
108
|
+
return word[0..-2] if word.end_with?("s") && !word.end_with?("ss")
|
|
109
|
+
word
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|