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
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqlChatbot
|
|
4
|
+
module Services
|
|
5
|
+
class CodeIndexer
|
|
6
|
+
SUPPORTED_EXTENSIONS = Set.new(%w[
|
|
7
|
+
.js .ts .jsx .tsx .rb .py .erb .vue .php .java .go .cs .ex .exs .svelte .kt .rs .dart .scala
|
|
8
|
+
]).freeze
|
|
9
|
+
|
|
10
|
+
SKIP_DIRS = Set.new(%w[
|
|
11
|
+
node_modules .git dist build vendor tmp __pycache__ .next .svelte-kit .nuxt target bin obj deps _build
|
|
12
|
+
]).freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_MAX_FILES = 2000
|
|
15
|
+
MAX_FILE_SIZE = 100_000 # 100KB — skip vendor/minified JS libraries
|
|
16
|
+
CONTEXT_LINES = 10
|
|
17
|
+
MAX_SNIPPET_LINES = 50
|
|
18
|
+
MAX_RESULTS = 10
|
|
19
|
+
|
|
20
|
+
IndexedFile = Struct.new(:relative_path, :content)
|
|
21
|
+
RouteInfo = Struct.new(:method, :path, :file, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
attr_reader :file_count
|
|
24
|
+
|
|
25
|
+
def initialize(max_files: DEFAULT_MAX_FILES)
|
|
26
|
+
@max_files = max_files
|
|
27
|
+
@files = []
|
|
28
|
+
@routes = []
|
|
29
|
+
@file_count = 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def index(code_paths)
|
|
33
|
+
@files = []
|
|
34
|
+
@routes = []
|
|
35
|
+
|
|
36
|
+
code_paths.each do |code_path|
|
|
37
|
+
break if @files.length >= @max_files
|
|
38
|
+
scan_directory(code_path, code_path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if @files.length > @max_files
|
|
42
|
+
@files = @files.first(@max_files)
|
|
43
|
+
warn "CodeIndexer: file cap reached (#{@max_files}). Some files were not indexed."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@file_count = @files.length
|
|
47
|
+
detect_routes
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def search(terms)
|
|
51
|
+
lower_terms = terms.map(&:downcase)
|
|
52
|
+
results = []
|
|
53
|
+
|
|
54
|
+
@files.each do |file|
|
|
55
|
+
lower_content = file.content.downcase
|
|
56
|
+
match_count = lower_terms.count { |term| lower_content.include?(term) }
|
|
57
|
+
next if match_count == 0
|
|
58
|
+
|
|
59
|
+
lines = file.content.split("\n")
|
|
60
|
+
matched_line_indices = Set.new
|
|
61
|
+
|
|
62
|
+
lower_terms.each do |term|
|
|
63
|
+
lines.each_with_index do |line, i|
|
|
64
|
+
matched_line_indices.add(i) if line.downcase.include?(term)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build snippet with context (+-10 lines around each match, max ~50 lines)
|
|
69
|
+
include_lines = Set.new
|
|
70
|
+
matched_line_indices.each do |idx|
|
|
71
|
+
start_line = [0, idx - CONTEXT_LINES].max
|
|
72
|
+
end_line = [lines.length - 1, idx + CONTEXT_LINES].min
|
|
73
|
+
(start_line..end_line).each { |i| include_lines.add(i) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sorted_lines = include_lines.to_a.sort.first(MAX_SNIPPET_LINES)
|
|
77
|
+
snippet = sorted_lines.map { |i| lines[i] }.join("\n")
|
|
78
|
+
|
|
79
|
+
results << {
|
|
80
|
+
file: file.relative_path,
|
|
81
|
+
content: snippet,
|
|
82
|
+
match_count: match_count
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Sort by match_count descending, take top 10
|
|
87
|
+
results.sort_by! { |r| -r[:match_count] }
|
|
88
|
+
results.first(MAX_RESULTS)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def get_routes
|
|
92
|
+
@routes.map { |r| { method: r.method, path: r.path, file: r.file } }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def get_route_summary
|
|
96
|
+
return "No routes detected." if @routes.empty?
|
|
97
|
+
|
|
98
|
+
lines = @routes.map { |r| "#{r.method} #{r.path} -> #{r.file}" }
|
|
99
|
+
"Routes detected:\n#{lines.join("\n")}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def scan_directory(dir, base_path)
|
|
105
|
+
return if @files.length >= @max_files
|
|
106
|
+
|
|
107
|
+
entries = begin
|
|
108
|
+
Dir.entries(dir)
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
entries.sort.each do |entry|
|
|
114
|
+
break if @files.length >= @max_files
|
|
115
|
+
next if entry == "." || entry == ".."
|
|
116
|
+
|
|
117
|
+
full_path = File.join(dir, entry)
|
|
118
|
+
|
|
119
|
+
if File.directory?(full_path)
|
|
120
|
+
next if SKIP_DIRS.include?(entry)
|
|
121
|
+
scan_directory(full_path, base_path)
|
|
122
|
+
elsif File.file?(full_path)
|
|
123
|
+
ext = File.extname(entry)
|
|
124
|
+
next unless SUPPORTED_EXTENSIONS.include?(ext)
|
|
125
|
+
|
|
126
|
+
# Skip large files (vendor/minified libraries)
|
|
127
|
+
begin
|
|
128
|
+
next if File.size(full_path) > MAX_FILE_SIZE
|
|
129
|
+
rescue SystemCallError
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
relative_path = compute_relative_path(full_path, base_path)
|
|
134
|
+
begin
|
|
135
|
+
content = File.read(full_path, encoding: "utf-8")
|
|
136
|
+
@files << IndexedFile.new(relative_path, content)
|
|
137
|
+
rescue SystemCallError, Encoding::InvalidByteSequenceError
|
|
138
|
+
# skip unreadable files
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def compute_relative_path(full_path, base_path)
|
|
145
|
+
# Ensure base_path ends with separator for clean relative path
|
|
146
|
+
base = base_path.end_with?("/") ? base_path : "#{base_path}/"
|
|
147
|
+
full_path.start_with?(base) ? full_path[base.length..] : full_path
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def detect_routes
|
|
151
|
+
@files.each do |file|
|
|
152
|
+
detect_method_call_routes(file)
|
|
153
|
+
detect_react_router_routes(file)
|
|
154
|
+
detect_rails_routes(file)
|
|
155
|
+
detect_config_routes(file)
|
|
156
|
+
detect_decorator_routes(file)
|
|
157
|
+
detect_sinatra_routes(file)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# --- Route detection methods ---
|
|
162
|
+
|
|
163
|
+
def detect_method_call_routes(file)
|
|
164
|
+
ext = File.extname(file.relative_path)
|
|
165
|
+
|
|
166
|
+
if %w[.js .ts .jsx .tsx].include?(ext)
|
|
167
|
+
# Express, Fastify, Hono, Koa
|
|
168
|
+
file.content.scan(/(?:app|router|server|fastify)\.(get|post|put|patch|delete)\(\s*['"`]([^'"`]+)['"`]/i) do |method, path|
|
|
169
|
+
@routes << RouteInfo.new(method: method.upcase, path: path, file: file.relative_path)
|
|
170
|
+
end
|
|
171
|
+
elsif ext == ".go"
|
|
172
|
+
# Gin, Echo, Fiber
|
|
173
|
+
file.content.scan(/\w+\.(GET|POST|PUT|PATCH|DELETE|Get|Post|Put|Patch|Delete)\(\s*"([^"]+)"/i) do |method, path|
|
|
174
|
+
@routes << RouteInfo.new(method: method.upcase, path: path, file: file.relative_path)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def detect_react_router_routes(file)
|
|
180
|
+
# <Route ... path="/something" ... />
|
|
181
|
+
file.content.scan(/<Route\b.*?path=["']([^"']+)["']/im) do |path,|
|
|
182
|
+
@routes << RouteInfo.new(method: "GET", path: path, file: file.relative_path)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# path: '/something' in route config objects (only if file has route-related imports)
|
|
186
|
+
if file.content.match?(/useRoutes|createBrowserRouter|createRoutesFromElements/i)
|
|
187
|
+
file.content.scan(/path:\s*['"`]([^'"`]+)['"`]/i) do |path,|
|
|
188
|
+
@routes << RouteInfo.new(method: "GET", path: path, file: file.relative_path)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def detect_rails_routes(file)
|
|
194
|
+
return unless file.relative_path.end_with?("routes.rb")
|
|
195
|
+
|
|
196
|
+
content = file.content
|
|
197
|
+
|
|
198
|
+
# root 'controller#action'
|
|
199
|
+
if content.match?(/root\s+['"]/)
|
|
200
|
+
@routes << RouteInfo.new(method: "GET", path: "/", file: file.relative_path)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# resources :name
|
|
204
|
+
content.scan(/resources\s+:(\w+)/i) do |name,|
|
|
205
|
+
@routes << RouteInfo.new(method: "GET", path: "/#{name}", file: file.relative_path)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# get '/path', to: 'controller#action'
|
|
209
|
+
content.scan(/get\s+['"]([^'"]+)['"]/i) do |path,|
|
|
210
|
+
@routes << RouteInfo.new(method: "GET", path: path, file: file.relative_path)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# post '/path', to: 'controller#action'
|
|
214
|
+
content.scan(/post\s+['"]([^'"]+)['"]/i) do |path,|
|
|
215
|
+
@routes << RouteInfo.new(method: "POST", path: path, file: file.relative_path)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# put '/path', to: 'controller#action'
|
|
219
|
+
content.scan(/put\s+['"]([^'"]+)['"]/i) do |path,|
|
|
220
|
+
@routes << RouteInfo.new(method: "PUT", path: path, file: file.relative_path)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# delete '/path', to: 'controller#action'
|
|
224
|
+
content.scan(/delete\s+['"]([^'"]+)['"]/i) do |path,|
|
|
225
|
+
@routes << RouteInfo.new(method: "DELETE", path: path, file: file.relative_path)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def detect_config_routes(file)
|
|
230
|
+
ext = File.extname(file.relative_path)
|
|
231
|
+
filename = File.basename(file.relative_path)
|
|
232
|
+
|
|
233
|
+
# Django: urls.py with path(), re_path(), url()
|
|
234
|
+
if filename == "urls.py" || (ext == ".py" && file.content.include?("urlpatterns"))
|
|
235
|
+
file.content.scan(/(?:path|re_path|url)\(\s*['"]([^'"]*)['"]/i) do |path,|
|
|
236
|
+
cleaned = "/" + path.sub(/^\^/, "").sub(/\$$/, "")
|
|
237
|
+
@routes << RouteInfo.new(method: "ALL", path: cleaned, file: file.relative_path)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Laravel: Route::get('/path', ...), Route::resource('name', ...)
|
|
242
|
+
if ext == ".php"
|
|
243
|
+
file.content.scan(/Route::(get|post|put|patch|delete)\(\s*['"]([^'"]+)['"]/i) do |method, path|
|
|
244
|
+
@routes << RouteInfo.new(method: method.upcase, path: path, file: file.relative_path)
|
|
245
|
+
end
|
|
246
|
+
file.content.scan(/Route::resource\(\s*['"]([^'"]+)['"]/i) do |name,|
|
|
247
|
+
@routes << RouteInfo.new(method: "GET", path: "/#{name}", file: file.relative_path)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ASP.NET minimal APIs: app.MapGet("/path", ...)
|
|
252
|
+
if ext == ".cs"
|
|
253
|
+
file.content.scan(/app\.Map(Get|Post|Put|Patch|Delete)\(\s*"([^"]+)"/i) do |method, path|
|
|
254
|
+
@routes << RouteInfo.new(method: method.upcase, path: path, file: file.relative_path)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# ASP.NET attribute routing: [HttpGet("path")], [Route("path")]
|
|
258
|
+
file.content.scan(/\[Http(Get|Post|Put|Patch|Delete)\(\s*"([^"]+)"\s*\)\]/i) do |method, path|
|
|
259
|
+
@routes << RouteInfo.new(method: method.upcase, path: path, file: file.relative_path)
|
|
260
|
+
end
|
|
261
|
+
file.content.scan(/\[Route\(\s*"([^"]+)"\s*\)\]/i) do |path,|
|
|
262
|
+
@routes << RouteInfo.new(method: "ALL", path: path, file: file.relative_path)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Phoenix: get "/path", Controller, :action
|
|
267
|
+
if filename == "router.ex" || (ext == ".ex" && file.content.include?("Phoenix.Router"))
|
|
268
|
+
file.content.scan(/(get|post|put|patch|delete)\s+"([^"]+)"/i) do |method, path|
|
|
269
|
+
@routes << RouteInfo.new(method: method.upcase, path: path, file: file.relative_path)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def detect_decorator_routes(file)
|
|
275
|
+
ext = File.extname(file.relative_path)
|
|
276
|
+
|
|
277
|
+
# NestJS: @Controller('prefix') + @Get('subpath')
|
|
278
|
+
if %w[.ts .js].include?(ext)
|
|
279
|
+
controller_match = file.content.match(/@Controller\(\s*['"]([^'"]*)['"]\s*\)/)
|
|
280
|
+
if controller_match
|
|
281
|
+
prefix = controller_match[1]
|
|
282
|
+
file.content.scan(/@(Get|Post|Put|Patch|Delete)\(\s*['"]([^'"]*)['"]\s*\)/i) do |method, subpath|
|
|
283
|
+
full_path = "/" + [prefix, subpath].reject(&:empty?).join("/")
|
|
284
|
+
@routes << RouteInfo.new(method: method.upcase, path: full_path, file: file.relative_path)
|
|
285
|
+
end
|
|
286
|
+
# Match decorators with no path argument: @Get()
|
|
287
|
+
file.content.scan(/@(Get|Post|Put|Patch|Delete)\(\s*\)/i) do |method,|
|
|
288
|
+
@routes << RouteInfo.new(method: method.upcase, path: "/#{prefix}", file: file.relative_path)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# FastAPI: @app.get("/path") / @router.post("/path")
|
|
294
|
+
if ext == ".py" && !file.content.include?("urlpatterns")
|
|
295
|
+
file.content.scan(/@(?:app|router)\.(get|post|put|patch|delete)\(\s*['"]([^'"]+)['"]/i) do |method, path|
|
|
296
|
+
@routes << RouteInfo.new(method: method.upcase, path: path, file: file.relative_path)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Flask: @app.route('/path', methods=['GET', 'POST'])
|
|
301
|
+
if ext == ".py"
|
|
302
|
+
file.content.scan(/@app\.route\(\s*['"]([^'"]+)['"](?:,\s*methods=\[([^\]]+)\])?\s*\)/i) do |route_path, methods_str|
|
|
303
|
+
if methods_str
|
|
304
|
+
methods = methods_str.gsub(/['"]/, "").split(/\s*,\s*/)
|
|
305
|
+
methods.each do |method|
|
|
306
|
+
@routes << RouteInfo.new(method: method.strip.upcase, path: route_path, file: file.relative_path)
|
|
307
|
+
end
|
|
308
|
+
else
|
|
309
|
+
@routes << RouteInfo.new(method: "GET", path: route_path, file: file.relative_path)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Spring Boot: @GetMapping("/path") + class-level @RequestMapping("/prefix")
|
|
315
|
+
if ext == ".java"
|
|
316
|
+
request_mapping_match = file.content.match(/@RequestMapping\(\s*(?:value\s*=\s*)?["']([^"']+)["']/)
|
|
317
|
+
prefix = request_mapping_match ? request_mapping_match[1] : ""
|
|
318
|
+
|
|
319
|
+
file.content.scan(/@(Get|Post|Put|Patch|Delete)Mapping\(\s*(?:value\s*=\s*)?["']([^"']+)["']/i) do |method, subpath|
|
|
320
|
+
full_path = prefix.empty? ? subpath : prefix + subpath
|
|
321
|
+
@routes << RouteInfo.new(method: method.upcase, path: full_path, file: file.relative_path)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def detect_sinatra_routes(file)
|
|
327
|
+
return unless file.relative_path.end_with?(".rb")
|
|
328
|
+
# Exclude Rails routes.rb files (handled by detect_rails_routes)
|
|
329
|
+
return if file.relative_path.end_with?("routes.rb")
|
|
330
|
+
|
|
331
|
+
file.content.scan(/(get|post|put|patch|delete)\s+['"]([^'"]+)['"]\s+do/i) do |method, path|
|
|
332
|
+
@routes << RouteInfo.new(method: method.upcase, path: path, file: file.relative_path)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sql_chatbot/grammar/intent_extractor"
|
|
4
|
+
require "sql_chatbot/grammar/template_compiler"
|
|
5
|
+
require "sql_chatbot/grammar/miss_logger"
|
|
6
|
+
|
|
7
|
+
module SqlChatbot
|
|
8
|
+
module Services
|
|
9
|
+
class GrammarPipeline
|
|
10
|
+
def initialize(registry:, call_llm:, confidence_threshold: 0.7, miss_log_path: nil)
|
|
11
|
+
@registry = registry
|
|
12
|
+
@call_llm = call_llm
|
|
13
|
+
@confidence_threshold = confidence_threshold
|
|
14
|
+
@miss_log_path = miss_log_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def try(question:, history: [])
|
|
18
|
+
intent = Grammar::IntentExtractor.extract(
|
|
19
|
+
question: question,
|
|
20
|
+
registry: @registry,
|
|
21
|
+
history: history,
|
|
22
|
+
call_llm: @call_llm,
|
|
23
|
+
confidence_threshold: @confidence_threshold
|
|
24
|
+
)
|
|
25
|
+
result = Grammar::TemplateCompiler.compile(intent, @registry)
|
|
26
|
+
if result[:ok]
|
|
27
|
+
result.merge(intent: intent)
|
|
28
|
+
else
|
|
29
|
+
log_miss(question, result[:reason], intent)
|
|
30
|
+
result.merge(intent: intent)
|
|
31
|
+
end
|
|
32
|
+
rescue => e
|
|
33
|
+
log_miss(question, "grammar_exception: #{e.message}", nil)
|
|
34
|
+
{ ok: false, reason: "grammar_exception: #{e.message}" }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def log_miss(question, reason, extracted)
|
|
40
|
+
return unless @miss_log_path
|
|
41
|
+
Grammar::MissLogger.log(@miss_log_path, { question: question, reason: reason, extracted: extracted })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module SqlChatbot
|
|
6
|
+
module Services
|
|
7
|
+
class ModelIntrospector
|
|
8
|
+
IntrospectionResult = Struct.new(:annotations, :soft_delete_tables, :enum_soft_delete_tables, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Returns IntrospectionResult with:
|
|
11
|
+
# annotations — Hash[table_name => [annotation_strings]]
|
|
12
|
+
# soft_delete_tables — Set of tables using Paranoia/Discard gems
|
|
13
|
+
# enum_soft_delete_tables — Set of tables with enum deleted/archived values
|
|
14
|
+
def introspect
|
|
15
|
+
annotations = Hash.new { |h, k| h[k] = Set.new }
|
|
16
|
+
soft_delete_tables = Set.new
|
|
17
|
+
enum_soft_delete_tables = Set.new
|
|
18
|
+
|
|
19
|
+
models = discover_models
|
|
20
|
+
models.each do |model|
|
|
21
|
+
table = model.table_name
|
|
22
|
+
|
|
23
|
+
detect_enums(model, table, annotations, enum_soft_delete_tables)
|
|
24
|
+
detect_associations(model, table, annotations)
|
|
25
|
+
detect_soft_delete_gem(model, table, soft_delete_tables)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
IntrospectionResult.new(
|
|
29
|
+
annotations: annotations.transform_values(&:to_a),
|
|
30
|
+
soft_delete_tables: soft_delete_tables,
|
|
31
|
+
enum_soft_delete_tables: enum_soft_delete_tables
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def discover_models
|
|
38
|
+
return [] unless defined?(ActiveRecord::Base)
|
|
39
|
+
|
|
40
|
+
load_models
|
|
41
|
+
|
|
42
|
+
ActiveRecord::Base.descendants.select do |model|
|
|
43
|
+
!model.abstract_class? &&
|
|
44
|
+
model.respond_to?(:table_name) &&
|
|
45
|
+
safe_table_exists?(model)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def load_models
|
|
50
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
51
|
+
|
|
52
|
+
if Rails.application.config.eager_load
|
|
53
|
+
return # Production: already loaded
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Development/test: load model files via Zeitwerk if available
|
|
57
|
+
if defined?(Zeitwerk) && Rails.autoloaders.respond_to?(:main)
|
|
58
|
+
model_paths = Rails.application.paths["app/models"]&.to_a || []
|
|
59
|
+
model_paths.each do |path|
|
|
60
|
+
abs_path = Rails.root.join(path).to_s
|
|
61
|
+
Rails.autoloaders.main.eager_load_dir(abs_path) if Dir.exist?(abs_path)
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
# Fallback for older Rails: eager_load everything
|
|
65
|
+
Rails.application.eager_load!
|
|
66
|
+
end
|
|
67
|
+
rescue => e
|
|
68
|
+
warn "[SqlChatbot] ModelIntrospector: Could not load models: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def safe_table_exists?(model)
|
|
72
|
+
model.table_exists?
|
|
73
|
+
rescue => _e
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
SOFT_DELETE_LABELS = %w[deleted archived removed discarded].freeze
|
|
78
|
+
SOFT_DELETE_GEMS = ["Paranoia", "Discard::Model"].freeze
|
|
79
|
+
|
|
80
|
+
def detect_enums(model, table, annotations, enum_soft_delete_tables)
|
|
81
|
+
return unless model.respond_to?(:defined_enums)
|
|
82
|
+
|
|
83
|
+
model.defined_enums.each do |column, values|
|
|
84
|
+
next if values.empty?
|
|
85
|
+
|
|
86
|
+
formatted = values.map { |label, num| "#{label}=#{num}" }.join(", ")
|
|
87
|
+
annotations[table].add(" -- RAILS ENUM: #{column} values: #{formatted}")
|
|
88
|
+
|
|
89
|
+
# Detect enum-based soft delete patterns
|
|
90
|
+
values.each do |label, num|
|
|
91
|
+
if SOFT_DELETE_LABELS.include?(label.downcase)
|
|
92
|
+
annotations[table].add(" -- ENUM SOFT DELETE: #{column} != #{num} to exclude #{label.downcase} records (do NOT use deleted_at)")
|
|
93
|
+
enum_soft_delete_tables.add(table)
|
|
94
|
+
break
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def detect_soft_delete_gem(model, table, soft_delete_tables)
|
|
101
|
+
SOFT_DELETE_GEMS.each do |gem_module_name|
|
|
102
|
+
begin
|
|
103
|
+
mod = Object.const_get(gem_module_name)
|
|
104
|
+
if model.ancestors.include?(mod)
|
|
105
|
+
soft_delete_tables.add(table)
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
rescue NameError
|
|
109
|
+
# Gem not installed, skip
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if model.respond_to?(:acts_as_paranoid?) && model.acts_as_paranoid?
|
|
114
|
+
soft_delete_tables.add(table)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def detect_associations(model, table, annotations)
|
|
119
|
+
return unless model.respond_to?(:reflect_on_all_associations)
|
|
120
|
+
|
|
121
|
+
model.reflect_on_all_associations(:belongs_to).each do |reflection|
|
|
122
|
+
next if reflection.respond_to?(:polymorphic?) && reflection.polymorphic?
|
|
123
|
+
|
|
124
|
+
fk = reflection.foreign_key.to_s
|
|
125
|
+
target_class = reflection.class_name
|
|
126
|
+
standard_fk = "#{reflection.name}_id"
|
|
127
|
+
standard_class = reflection.name.to_s.split("_").map(&:capitalize).join
|
|
128
|
+
|
|
129
|
+
non_standard_fk = (fk != standard_fk)
|
|
130
|
+
non_standard_class = (target_class != standard_class)
|
|
131
|
+
|
|
132
|
+
next unless non_standard_fk || non_standard_class
|
|
133
|
+
|
|
134
|
+
target_table = resolve_table_name(target_class)
|
|
135
|
+
|
|
136
|
+
detail = "belongs_to :#{reflection.name}"
|
|
137
|
+
detail += ", class_name: \"#{target_class}\"" if non_standard_class
|
|
138
|
+
detail += ", foreign_key: \"#{fk}\"" if non_standard_fk
|
|
139
|
+
|
|
140
|
+
annotations[table].add(" -- MODEL FK: #{fk} -> #{target_table}.id (#{detail})")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def resolve_table_name(class_name)
|
|
145
|
+
class_name.constantize.table_name
|
|
146
|
+
rescue NameError
|
|
147
|
+
# Fallback: simple pluralization
|
|
148
|
+
class_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase + "s"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|