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
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