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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +20 -0
  4. data/app/controllers/sql_chatbot/chatbot_controller.rb +158 -0
  5. data/config/routes.rb +11 -0
  6. data/lib/generators/sql_chatbot/install_generator.rb +25 -0
  7. data/lib/generators/sql_chatbot/templates/initializer.rb +22 -0
  8. data/lib/sql_chatbot/auth/cors.rb +35 -0
  9. data/lib/sql_chatbot/auth/jwt.rb +34 -0
  10. data/lib/sql_chatbot/configuration.rb +58 -0
  11. data/lib/sql_chatbot/engine.rb +23 -0
  12. data/lib/sql_chatbot/grammar/count_renderer.rb +113 -0
  13. data/lib/sql_chatbot/grammar/entity_candidates.rb +210 -0
  14. data/lib/sql_chatbot/grammar/intent_extractor.rb +191 -0
  15. data/lib/sql_chatbot/grammar/list_renderer.rb +50 -0
  16. data/lib/sql_chatbot/grammar/miss_logger.rb +17 -0
  17. data/lib/sql_chatbot/grammar/modifiers.rb +145 -0
  18. data/lib/sql_chatbot/grammar/primitives.rb +69 -0
  19. data/lib/sql_chatbot/grammar/programmatic_renderer.rb +258 -0
  20. data/lib/sql_chatbot/grammar/registry.rb +66 -0
  21. data/lib/sql_chatbot/grammar/sanity_check.rb +37 -0
  22. data/lib/sql_chatbot/grammar/template_compiler.rb +179 -0
  23. data/lib/sql_chatbot/llm/client.rb +87 -0
  24. data/lib/sql_chatbot/prompts/answer.rb +157 -0
  25. data/lib/sql_chatbot/prompts/classify.rb +59 -0
  26. data/lib/sql_chatbot/prompts/generate_sql.rb +88 -0
  27. data/lib/sql_chatbot/services/code_indexer.rb +337 -0
  28. data/lib/sql_chatbot/services/grammar_pipeline.rb +45 -0
  29. data/lib/sql_chatbot/services/model_introspector.rb +152 -0
  30. data/lib/sql_chatbot/services/orchestrator.rb +635 -0
  31. data/lib/sql_chatbot/services/registry_builder.rb +385 -0
  32. data/lib/sql_chatbot/services/route_introspector.rb +118 -0
  33. data/lib/sql_chatbot/services/schema_service.rb +884 -0
  34. data/lib/sql_chatbot/services/sql_executor.rb +81 -0
  35. data/lib/sql_chatbot/version.rb +5 -0
  36. data/lib/sql_chatbot_rails.rb +91 -0
  37. data/vendor/assets/widget.js +53 -0
  38. 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