pattern-ruby 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76b9f40922a19e84b9da9c7a0ccb8027b6a3d99f75782a248c8edd3111bf063f
4
- data.tar.gz: b7d845a9dff5e5e479d3f74e5268d118da67cbe821450be6bb5e32586402a333
3
+ metadata.gz: f811da9c8851dd1ac42ed24ab7db8b93a05bcefd61d9c76a66676e3c97cbdf15
4
+ data.tar.gz: 3b282051dfbd99a38a5960d6ce46e7637b5ba63a1c2d9bbcae908ba58af7e558
5
5
  SHA512:
6
- metadata.gz: de646d6f5b0c9a8924a835986b55c4c33ae10da4b96040ce53e5ba6392ecf40520879d66f39f2655f88d84745a41ea7856f6d5a42b4653483eaa53080ce7aed9
7
- data.tar.gz: 3fea24e0b08f2620e4b3da933a60d44abc9371283feab5648b2c8630f5fd69b41a0bf8e37c5c18065e678f491ee2f5f5c5cc2bc804c92437de46a412ad167512
6
+ metadata.gz: 13de001ca6e9657cf5faa5ec36bf5ffc5522d957fd54b92c7b13a7d72e0748582f566208dc8b93307d78aa44aa5607ec0234a73443a36bb8ec75a8eb763544dc
7
+ data.tar.gz: db8cd6c927e091c6bbdacc9a50e75e454041342fb2cfd9378ffc9687715435aafa9e01544d2be9b20a09f617c7522283e028dd3b6f4b5e90dde189b7cb865c11
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A deterministic pattern detection and intent matching engine for Ruby. Built for chatbots and conversational AI where you need fast, predictable, rule-based matching — no API calls, no model inference, no latency.
4
4
 
5
+ **Why pattern-ruby?** LLMs are great but slow, expensive, and unpredictable. For the 80% of user inputs that follow predictable patterns ("track my order", "reset password", "what are your hours"), you don't need an LLM. Handle those deterministically in microseconds, and route the rest to your LLM.
6
+
5
7
  ## Installation
6
8
 
7
9
  ```ruby
@@ -32,72 +34,71 @@ router = PatternRuby::Router.new do
32
34
  entity :city, type: :string
33
35
  end
34
36
 
35
- fallback { |input| puts "Unknown: #{input}" }
37
+ intent :order_status do
38
+ pattern "track order {order_id:\\d+}"
39
+ pattern "where is my order {order_id:\\d+}"
40
+ pattern(/order\s*#?\s*(?<order_id>\d{5,})/i)
41
+ end
42
+
43
+ fallback { |input| puts "Route to LLM: #{input}" }
36
44
  end
37
45
 
38
46
  result = router.match("weather in Tokyo")
39
47
  result.intent # => :weather
40
48
  result.entities # => { city: "Tokyo" }
41
49
  result.matched? # => true
50
+ result.score # => 0.9
42
51
  ```
43
52
 
44
53
  ## Pattern Syntax
45
54
 
46
- ```
47
- hello Literal match (case-insensitive)
48
- {name} Named entity capture
49
- {name:\d+} Entity with regex constraint
50
- {name:opt1|opt2|opt3} Entity with enum constraint
51
- [optional words] Optional section
52
- (alt1|alt2|alt3) Alternatives
53
- * Wildcard (any text)
54
- ```
55
+ | Syntax | Meaning | Example |
56
+ |---|---|---|
57
+ | `hello` | Literal match (case-insensitive) | matches "Hello", "HELLO" |
58
+ | `{name}` | Named entity capture | `{city}` captures any text |
59
+ | `{name:\\d+}` | Entity with regex constraint | `{id:\\d+}` captures digits only |
60
+ | `{name:a\|b\|c}` | Entity with enum constraint | `{time:today\|tomorrow}` |
61
+ | `[optional]` | Optional section | `book [a] flight` |
62
+ | `(a\|b\|c)` | Alternatives | `(fly\|travel\|go)` |
63
+ | `*` | Wildcard | `tell me *` matches anything |
55
64
 
56
- ### Examples
65
+ ### Combined Example
57
66
 
58
67
  ```ruby
59
- # Optional words
60
- "book [a] flight" # matches "book a flight" and "book flight"
61
-
62
- # Entity with constraint
63
- "order {id:\d+}" # captures only digits
64
-
65
- # Alternatives
66
- "i want to (fly|travel|go)" # matches any of the three verbs
67
-
68
- # Combined
68
+ # All of these match:
69
69
  "book [a] flight from {origin} to {destination} [on {date}]"
70
+
71
+ # "book a flight from NYC to London on Friday"
72
+ # "book flight from Paris to Berlin"
73
+ # "book a flight from Tokyo to Seoul"
70
74
  ```
71
75
 
72
76
  ## Entity Types
73
77
 
74
- Built-in types: `:string`, `:number`, `:integer`, `:email`, `:phone`, `:url`, `:currency`
78
+ Built-in: `:string`, `:number`, `:integer`, `:email`, `:phone`, `:url`, `:currency`
75
79
 
76
80
  ```ruby
77
- # Register custom entity types
78
81
  router = PatternRuby::Router.new do
82
+ # Custom entity types
79
83
  entity_type :zipcode, pattern: /\d{5}/, parser: ->(s) { s.to_i }
80
84
 
81
85
  intent :locate do
82
- pattern "find stores near {zip}"
86
+ pattern "stores near {zip}"
83
87
  entity :zip, type: :zipcode
84
88
  end
85
- end
86
- ```
87
89
 
88
- Entities support defaults:
89
-
90
- ```ruby
91
- intent :weather do
92
- pattern "weather in {city}"
93
- entity :city, type: :string
94
- entity :time, type: :string, default: "today"
90
+ # Default values
91
+ intent :weather do
92
+ pattern "weather in {city}"
93
+ entity :city, type: :string
94
+ entity :time, type: :string, default: "today"
95
+ end
95
96
  end
96
97
  ```
97
98
 
98
99
  ## Regex Patterns
99
100
 
100
- Use raw regex for complex matching:
101
+ For complex matching, use raw regex with named captures:
101
102
 
102
103
  ```ruby
103
104
  intent :order_status do
@@ -108,43 +109,49 @@ result = router.match("Where is order #12345?")
108
109
  result.entities[:order_id] # => "12345"
109
110
  ```
110
111
 
111
- ## Multi-Intent Matching
112
-
113
- ```ruby
114
- results = router.match_all("book a flight to Paris")
115
- results.each do |r|
116
- puts "#{r.intent}: #{r.score}"
117
- end
118
- ```
119
-
120
112
  ## Context-Aware Matching
121
113
 
114
+ Route the same input differently based on conversation state:
115
+
122
116
  ```ruby
123
117
  router = PatternRuby::Router.new do
124
118
  intent :confirm do
125
119
  pattern "yes"
126
- context :awaiting_confirmation
120
+ context :awaiting_confirmation # only matches in this context
127
121
  end
128
122
 
129
- intent :greeting do
130
- pattern "yes" # only matches without context
123
+ intent :affirmative_greeting do
124
+ pattern "yes" # matches when no context
131
125
  end
132
126
  end
133
127
 
134
- router.match("yes") # => :greeting
128
+ router.match("yes") # => :affirmative_greeting
135
129
  router.match("yes", context: :awaiting_confirmation) # => :confirm
136
130
  ```
137
131
 
138
- ## Conversation State
132
+ ## Multi-Intent Matching
133
+
134
+ Get all matching intents ranked by score:
135
+
136
+ ```ruby
137
+ results = router.match_all("book a flight to Paris")
138
+ results.each { |r| puts "#{r.intent}: #{r.score}" }
139
+ # book_flight: 0.92
140
+ # book_generic: 0.65
141
+ ```
142
+
143
+ ## Conversation State & Slot Filling
144
+
145
+ Track conversation context and fill missing entities across turns:
139
146
 
140
147
  ```ruby
141
148
  convo = PatternRuby::Conversation.new(router)
142
149
 
143
150
  result = convo.process("book a flight from NYC to London")
144
- # => { origin: "NYC", destination: "London" }
151
+ # => entities: { origin: "NYC", destination: "London" }
145
152
 
146
- result = convo.process("tomorrow")
147
- # => fills missing date slot: { origin: "NYC", destination: "London", date: "tomorrow" }
153
+ result = convo.process("Friday")
154
+ # => fills missing slot: { origin: "NYC", destination: "London", date: "Friday" }
148
155
 
149
156
  convo.reset # clear all state
150
157
  ```
@@ -161,26 +168,80 @@ pipeline = PatternRuby::Pipeline.new do
161
168
  end
162
169
 
163
170
  result = pipeline.process("Wether in Tokyo?")
164
- # => intent: :weather, entities: { city: "tokyo" }
171
+ # normalize "wether in tokyo"
172
+ # correct → "weather in tokyo"
173
+ # match → intent: :weather, entities: { city: "tokyo" }
165
174
  ```
166
175
 
176
+ ## Real-World Example: Support Bot
177
+
178
+ ```ruby
179
+ router = PatternRuby::Router.new do
180
+ intent :order_status do
181
+ pattern "where is my order {order_id:\\d+}"
182
+ pattern "track order {order_id:\\d+}"
183
+ pattern(/order\s*#?\s*(?<order_id>\d{5,})/i)
184
+ end
185
+
186
+ intent :refund do
187
+ pattern "i want [a] refund"
188
+ pattern "refund [my] order {order_id:\\d+}"
189
+ end
190
+
191
+ intent :reset_password do
192
+ pattern "reset [my] password"
193
+ pattern "forgot [my] password"
194
+ pattern "can't log in"
195
+ end
196
+
197
+ intent :cancel do
198
+ pattern "cancel [my] (account|subscription|plan)"
199
+ pattern "i want to cancel"
200
+ end
201
+
202
+ intent :contact do
203
+ pattern "talk to [a] (human|person|agent)"
204
+ pattern "i need [to talk to] a human"
205
+ end
206
+
207
+ intent :complaint do
208
+ pattern "this is (terrible|awful|horrible)"
209
+ pattern "i'm (unhappy|frustrated|angry)"
210
+ pattern "i want to (complain|file a complaint)"
211
+ end
212
+
213
+ fallback { |input| route_to_llm(input) }
214
+ end
215
+
216
+ # In your controller/handler:
217
+ result = router.match(user_input)
218
+ case result.intent
219
+ when :order_status then lookup_order(result.entities[:order_id])
220
+ when :refund then initiate_refund(result.entities[:order_id])
221
+ when :complaint then escalate_to_human(user_input)
222
+ when nil then ask_llm(user_input) # fallback
223
+ end
224
+ ```
225
+
226
+ Try the interactive demo: `ruby -Ilib examples/support_bot.rb`
227
+
167
228
  ## Test Helpers
168
229
 
169
230
  ```ruby
170
231
  require "pattern_ruby/rails/test_helpers"
171
232
 
172
- class MyTest < Minitest::Test
233
+ class ChatbotTest < Minitest::Test
173
234
  include PatternRuby::TestHelpers
174
235
 
175
236
  def test_greeting
176
237
  assert_matches_intent :greeting, "hello", router: @router
177
238
  end
178
239
 
179
- def test_entity
240
+ def test_entity_extraction
180
241
  assert_extracts_entity :city, "Tokyo", "weather in Tokyo", router: @router
181
242
  end
182
243
 
183
- def test_no_match
244
+ def test_unknown_input
184
245
  refute_matches "quantum physics", router: @router
185
246
  end
186
247
  end
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env ruby
2
+ # A realistic customer support chatbot using pattern-ruby.
3
+ # Run: ruby -Ilib examples/support_bot.rb
4
+
5
+ require "pattern_ruby"
6
+
7
+ # Simulated database
8
+ ORDERS = {
9
+ "10001" => { status: "delivered", item: "Ruby Cookbook", date: "2026-03-01" },
10
+ "10002" => { status: "shipped", item: "Mechanical Keyboard", date: "2026-03-05", tracking: "1Z999AA10123456784" },
11
+ "10003" => { status: "processing", item: "USB-C Hub", date: "2026-03-07" },
12
+ }
13
+
14
+ PRODUCTS = {
15
+ "basic" => { price: "$9/mo", features: "5 projects, 1GB storage" },
16
+ "pro" => { price: "$29/mo", features: "Unlimited projects, 50GB storage, priority support" },
17
+ "enterprise" => { price: "$99/mo", features: "Everything in Pro + SSO, audit logs, SLA" },
18
+ }
19
+
20
+ HOURS = "Monday-Friday, 9am-5pm EST"
21
+
22
+ # Build the router
23
+ router = PatternRuby::Router.new do
24
+ # --- Greetings ---
25
+ intent :greeting do
26
+ pattern "hello"
27
+ pattern "hi"
28
+ pattern "hey"
29
+ pattern "good morning"
30
+ pattern "good afternoon"
31
+ pattern "good evening"
32
+ pattern "hi there"
33
+ pattern "hello there"
34
+ pattern "hey there"
35
+ end
36
+
37
+ intent :farewell do
38
+ pattern "bye"
39
+ pattern "goodbye"
40
+ pattern "see you"
41
+ pattern "thanks bye"
42
+ pattern "that's all"
43
+ end
44
+
45
+ # --- Order tracking ---
46
+ intent :order_status do
47
+ pattern "where is my order {order_id:\\d+}"
48
+ pattern "track order {order_id:\\d+}"
49
+ pattern "order status {order_id:\\d+}"
50
+ pattern "check order {order_id:\\d+}"
51
+ pattern(/order\s*#?\s*(?<order_id>\d{4,})/i)
52
+ entity :order_id, type: :string
53
+ end
54
+
55
+ # --- Product / pricing ---
56
+ intent :pricing do
57
+ pattern "how much does {plan:basic|pro|enterprise} cost"
58
+ pattern "price of {plan:basic|pro|enterprise}"
59
+ pattern "pricing"
60
+ pattern "plans"
61
+ pattern "what plans do you have"
62
+ entity :plan, type: :string
63
+ end
64
+
65
+ intent :compare_plans do
66
+ pattern "compare plans"
67
+ pattern "difference between plans"
68
+ pattern "which plan should i (choose|pick|get)"
69
+ pattern "compare {plan_a:basic|pro|enterprise} and {plan_b:basic|pro|enterprise}"
70
+ end
71
+
72
+ # --- Account ---
73
+ intent :reset_password do
74
+ pattern "reset [my] password"
75
+ pattern "forgot [my] password"
76
+ pattern "can't log in"
77
+ pattern "change [my] password"
78
+ pattern "locked out [of my account]"
79
+ end
80
+
81
+ intent :cancel_account do
82
+ pattern "cancel [my] (account|subscription|plan)"
83
+ pattern "i want to cancel"
84
+ pattern "delete [my] account"
85
+ pattern "unsubscribe"
86
+ end
87
+
88
+ # --- Refund ---
89
+ intent :refund do
90
+ pattern "i want [a] refund"
91
+ pattern "refund [my] order {order_id:\\d+}"
92
+ pattern "can i get [a] refund"
93
+ pattern "money back"
94
+ entity :order_id, type: :string
95
+ end
96
+
97
+ # --- Hours / contact ---
98
+ intent :business_hours do
99
+ pattern "what are your (hours|business hours)"
100
+ pattern "when are you open"
101
+ pattern "hours of operation"
102
+ pattern "are you open (today|tomorrow|on weekends)"
103
+ end
104
+
105
+ intent :contact do
106
+ pattern "how (can|do) i contact (you|support|a human)"
107
+ pattern "talk to [a] (human|person|agent|representative)"
108
+ pattern "phone number"
109
+ pattern "email address"
110
+ pattern "i need [to talk to] a human"
111
+ end
112
+
113
+ # --- Help ---
114
+ intent :help do
115
+ pattern "help"
116
+ pattern "what can you do"
117
+ pattern "what do you do"
118
+ pattern "menu"
119
+ end
120
+
121
+ # --- Complaint ---
122
+ intent :complaint do
123
+ pattern "this is (terrible|awful|horrible|unacceptable)"
124
+ pattern "i'm (unhappy|frustrated|angry|disappointed)"
125
+ pattern "your (service|product|app) is (bad|broken|terrible)"
126
+ pattern "i want to (complain|file a complaint)"
127
+ pattern "not working"
128
+ end
129
+
130
+ fallback { |_| }
131
+ end
132
+
133
+ # Build preprocessing pipeline
134
+ pipeline = PatternRuby::Pipeline.new do
135
+ step(:normalize) do |input|
136
+ input.strip
137
+ .gsub(/[.!?]+$/, "") # strip trailing punctuation
138
+ .gsub(/\s+/, " ") # normalize whitespace
139
+ end
140
+
141
+ step(:correct) do |input|
142
+ input
143
+ .gsub(/\bpasword\b/i, "password")
144
+ .gsub(/\baccout\b/i, "account")
145
+ .gsub(/\bordrer\b/i, "order")
146
+ .gsub(/\bprcing\b/i, "pricing")
147
+ .gsub(/\bcansel\b/i, "cancel")
148
+ end
149
+ end
150
+
151
+ # Response handler
152
+ def handle(result)
153
+ case result.intent
154
+ when :greeting
155
+ "Hello! Welcome to Acme Support. How can I help you today?\n Type 'help' to see what I can do."
156
+
157
+ when :farewell
158
+ "Thanks for contacting Acme Support! Have a great day. 👋"
159
+
160
+ when :order_status
161
+ oid = result.entities[:order_id]
162
+ order = ORDERS[oid]
163
+ if order
164
+ msg = "Order ##{oid}: #{order[:item]}\n"
165
+ msg += " Status: #{order[:status].upcase}\n"
166
+ msg += " Ordered: #{order[:date]}\n"
167
+ msg += " Tracking: #{order[:tracking]}" if order[:tracking]
168
+ msg
169
+ else
170
+ "I couldn't find order ##{oid}. Please check the order number and try again.\n (Try: 10001, 10002, or 10003)"
171
+ end
172
+
173
+ when :pricing
174
+ plan = result.entities[:plan]
175
+ if plan
176
+ info = PRODUCTS[plan]
177
+ "#{plan.capitalize} Plan: #{info[:price]}\n Features: #{info[:features]}"
178
+ else
179
+ PRODUCTS.map { |k, v| " #{k.capitalize}: #{v[:price]} — #{v[:features]}" }.join("\n")
180
+ end
181
+
182
+ when :compare_plans
183
+ PRODUCTS.map { |k, v| " #{k.capitalize}: #{v[:price]} — #{v[:features]}" }.join("\n") +
184
+ "\n → Most popular: Pro plan"
185
+
186
+ when :reset_password
187
+ "To reset your password:\n 1. Go to acme.com/reset\n 2. Enter your email\n 3. Check your inbox for a reset link\n Link expires in 1 hour."
188
+
189
+ when :cancel_account
190
+ "I'm sorry to hear that. To cancel:\n 1. Go to Settings → Billing\n 2. Click 'Cancel Plan'\n Or reply with your reason and I'll connect you with our retention team."
191
+
192
+ when :refund
193
+ oid = result.entities[:order_id]
194
+ if oid
195
+ "I've submitted a refund request for order ##{oid}. You'll receive a confirmation email within 24 hours."
196
+ else
197
+ "I can help with a refund. Could you provide your order number?\n (e.g., 'refund order 10001')"
198
+ end
199
+
200
+ when :business_hours
201
+ "Our support hours are: #{HOURS}\n For urgent issues outside hours, email urgent@acme.com"
202
+
203
+ when :contact
204
+ "You can reach us via:\n Email: support@acme.com\n Phone: 1-800-ACME-123\n Live chat: acme.com/chat\n Or I can connect you with a human agent now."
205
+
206
+ when :help
207
+ <<~HELP
208
+ I can help with:
209
+ • Order tracking — "track order 10001"
210
+ • Pricing & plans — "pricing" or "compare plans"
211
+ • Password reset — "reset password"
212
+ • Refunds — "refund order 10001"
213
+ • Business hours — "what are your hours"
214
+ • Contact support — "talk to a human"
215
+ • Cancel account — "cancel subscription"
216
+ HELP
217
+
218
+ when :complaint
219
+ "I'm really sorry you're having a bad experience. Let me connect you with a senior support agent who can help resolve this right away.\n [Escalating to human agent...]"
220
+
221
+ else
222
+ "I'm not sure I understand. Could you rephrase that?\n Type 'help' to see what I can assist with."
223
+ end
224
+ end
225
+
226
+ # --- Interactive REPL ---
227
+ puts "╔══════════════════════════════════════╗"
228
+ puts "║ Acme Support Bot (pattern-ruby) ║"
229
+ puts "║ Type 'quit' to exit ║"
230
+ puts "╚══════════════════════════════════════╝"
231
+ puts
232
+
233
+ convo = PatternRuby::Conversation.new(router)
234
+
235
+ loop do
236
+ print "You: "
237
+ input = $stdin.gets
238
+ break unless input
239
+ input = input.strip
240
+ break if input.downcase == "quit" || input.downcase == "exit"
241
+ next if input.empty?
242
+
243
+ # Preprocess then match
244
+ cleaned = pipeline.process(input)
245
+ result = convo.process(cleaned)
246
+
247
+ response = handle(result)
248
+ puts "Bot: #{response}"
249
+ puts
250
+
251
+ break if result.intent == :farewell
252
+ end
@@ -7,60 +7,65 @@ module PatternRuby
7
7
  @context = nil
8
8
  @history = []
9
9
  @slots = {}
10
+ @mutex = Mutex.new
10
11
  end
11
12
 
12
13
  def process(input)
13
- result = @router.match(input, context: @context)
14
+ @mutex.synchronize do
15
+ result = @router.match(input, context: @context)
14
16
 
15
- if result.matched?
16
- # Merge new entities into accumulated slots
17
- result.entities.each do |k, v|
18
- @slots[k] = v unless v.nil?
19
- end
17
+ if result.matched?
18
+ # Merge new entities into accumulated slots
19
+ result.entities.each do |k, v|
20
+ @slots[k] = v unless v.nil?
21
+ end
20
22
 
21
- # Build enriched result with accumulated slots
22
- result = MatchResult.new(
23
- intent: result.intent,
24
- entities: @slots.dup,
25
- pattern: result.pattern,
26
- score: result.score,
27
- input: result.input,
28
- response: result.response
29
- )
30
- elsif !@history.empty? && @slots.any?
31
- # Try to fill a missing slot from context
32
- last = @history.last
33
- if last&.matched?
34
- filled = try_slot_fill(input, last.intent)
35
- if filled
36
- result = MatchResult.new(
37
- intent: last.intent,
38
- entities: @slots.dup,
39
- pattern: last.pattern,
40
- score: last.score,
41
- input: input,
42
- response: last.response
43
- )
23
+ # Build enriched result with accumulated slots
24
+ result = MatchResult.new(
25
+ intent: result.intent,
26
+ entities: @slots.dup,
27
+ pattern: result.pattern,
28
+ score: result.score,
29
+ input: result.input,
30
+ response: result.response
31
+ )
32
+ elsif !@history.empty? && @slots.any?
33
+ # Try to fill a missing slot from context
34
+ last = @history.last
35
+ if last&.matched?
36
+ filled = try_slot_fill(input, last.intent)
37
+ if filled
38
+ result = MatchResult.new(
39
+ intent: last.intent,
40
+ entities: @slots.dup,
41
+ pattern: last.pattern,
42
+ score: last.score,
43
+ input: input,
44
+ response: last.response
45
+ )
46
+ end
44
47
  end
45
48
  end
46
- end
47
49
 
48
- @history << result
49
- result
50
+ @history << result
51
+ result
52
+ end
50
53
  end
51
54
 
52
55
  def set_context(ctx)
53
- @context = ctx
56
+ @mutex.synchronize { @context = ctx }
54
57
  end
55
58
 
56
59
  def clear_context
57
- @context = nil
60
+ @mutex.synchronize { @context = nil }
58
61
  end
59
62
 
60
63
  def reset
61
- @context = nil
62
- @history = []
63
- @slots = {}
64
+ @mutex.synchronize do
65
+ @context = nil
66
+ @history = []
67
+ @slots = {}
68
+ end
64
69
  end
65
70
 
66
71
  private
@@ -74,7 +79,7 @@ module PatternRuby
74
79
  return false if unfilled.empty?
75
80
 
76
81
  # Try the input as a value for the first unfilled slot
77
- name, defn = unfilled.first
82
+ name, _defn = unfilled.first
78
83
  @slots[name] = input.strip
79
84
  true
80
85
  end
@@ -10,17 +10,44 @@ module PatternRuby
10
10
  currency: { pattern: /\$[\d,]+(?:\.\d{2})?/, parser: ->(s) { s.delete(",").delete("$").to_f } },
11
11
  }.freeze
12
12
 
13
+ # Standard NER entity types recognized in pattern constraints like {entity:PERSON}
14
+ STANDARD_NER_TYPES = %w[
15
+ PERSON PER
16
+ LOCATION LOC
17
+ ORGANIZATION ORG
18
+ DATE TIME
19
+ MONEY PERCENT
20
+ MISC
21
+ ].freeze
22
+
23
+ # Validates an entity type string used in pattern constraints (e.g., "PERSON" from {name:PERSON}).
24
+ # Returns true if the type is a standard NER type, a built-in type, or matches the custom type
25
+ # format (UPPER_SNAKE_CASE).
26
+ def self.valid_entity_type?(type_str)
27
+ return false if type_str.nil? || type_str.strip.empty?
28
+
29
+ normalized = type_str.strip
30
+ return true if STANDARD_NER_TYPES.include?(normalized)
31
+ return true if BUILT_IN.key?(normalized.downcase.to_sym)
32
+
33
+ # Allow custom types that follow UPPER_SNAKE_CASE or CamelCase convention
34
+ !!(normalized.match?(/\A[A-Z][A-Z0-9_]*\z/) || normalized.match?(/\A[A-Z][a-zA-Z0-9]*\z/))
35
+ end
36
+
13
37
  class Registry
14
38
  def initialize
15
39
  @types = BUILT_IN.dup
40
+ @mutex = Mutex.new
16
41
  end
17
42
 
18
43
  def register(name, pattern: nil, parser: nil)
19
- @types[name.to_sym] = { pattern: pattern, parser: parser }
44
+ @mutex.synchronize do
45
+ @types[name.to_sym] = { pattern: pattern, parser: parser }
46
+ end
20
47
  end
21
48
 
22
49
  def get(name)
23
- @types[name.to_sym]
50
+ @mutex.synchronize { @types[name.to_sym] }
24
51
  end
25
52
 
26
53
  def pattern_for(name)
@@ -14,8 +14,11 @@ module PatternRuby
14
14
  def pattern(pat)
15
15
  if pat.is_a?(Regexp)
16
16
  @compiled_patterns << RegexPattern.new(pat)
17
- else
17
+ elsif pat.is_a?(String)
18
+ raise ArgumentError, "pattern cannot be empty" if pat.strip.empty?
18
19
  @compiled_patterns << @compiler.compile(pat)
20
+ else
21
+ raise ArgumentError, "pattern must be a String or Regexp, got #{pat.class}"
19
22
  end
20
23
  end
21
24
 
@@ -17,11 +17,20 @@ module PatternRuby
17
17
  end
18
18
 
19
19
  class PatternCompiler
20
+ # Maximum pattern string length to prevent ReDoS / excessive compilation cost
21
+ MAX_PATTERN_LENGTH = 10_000
22
+
20
23
  def initialize(entity_registry: nil)
21
24
  @entity_registry = entity_registry
22
25
  end
23
26
 
24
27
  def compile(pattern_string)
28
+ raise ArgumentError, "pattern must be a String, got #{pattern_string.class}" unless pattern_string.is_a?(String)
29
+ raise ArgumentError, "pattern cannot be nil or empty" if pattern_string.nil? || pattern_string.strip.empty?
30
+ if pattern_string.length > MAX_PATTERN_LENGTH
31
+ raise ArgumentError, "pattern exceeds maximum length of #{MAX_PATTERN_LENGTH} characters"
32
+ end
33
+
25
34
  tokens = tokenize(pattern_string)
26
35
  entity_names = []
27
36
  literal_count = 0
@@ -131,6 +140,9 @@ module PatternRuby
131
140
  # Enum constraint: {time:today|tomorrow|this week}
132
141
  alts = token.constraint.split("|").map { |a| Regexp.escape(a.strip) }
133
142
  "(?<#{token.name}>#{alts.join('|')})"
143
+ elsif EntityTypes.valid_entity_type?(token.constraint)
144
+ # NER type constraint: {entity:PERSON} — treated as free-text capture
145
+ "(?<#{token.name}>.+?)"
134
146
  else
135
147
  # Regex constraint: {order_id:\\d+}
136
148
  "(?<#{token.name}>#{token.constraint})"
@@ -2,18 +2,32 @@ module PatternRuby
2
2
  class Router
3
3
  attr_reader :intents
4
4
 
5
+ # Maximum input length for matching (100 KB). Inputs beyond this are truncated.
6
+ MAX_INPUT_LENGTH = 100_000
7
+
5
8
  def initialize(&block)
6
9
  @intents = []
10
+ @intent_names = {}
7
11
  @fallback_handler = nil
8
12
  @entity_registry = EntityTypes::Registry.new
9
13
  @compiler = PatternCompiler.new(entity_registry: @entity_registry)
14
+ @mutex = Mutex.new
10
15
  instance_eval(&block) if block
11
16
  end
12
17
 
13
18
  def intent(name, &block)
19
+ name_sym = name.to_sym
20
+ if @intent_names.key?(name_sym)
21
+ # Duplicate registration: return existing intent (idempotent)
22
+ return @intent_names[name_sym]
23
+ end
24
+
14
25
  i = Intent.new(name, compiler: @compiler)
15
26
  i.instance_eval(&block) if block
16
- @intents << i
27
+ @mutex.synchronize do
28
+ @intents << i
29
+ @intent_names[name_sym] = i
30
+ end
17
31
  i
18
32
  end
19
33
 
@@ -26,12 +40,12 @@ module PatternRuby
26
40
  end
27
41
 
28
42
  def match(input, context: nil)
29
- input = input.to_s.strip
43
+ input = sanitize_input(input)
30
44
  return fallback_result(input) if input.empty?
31
45
 
32
46
  candidates = []
33
47
 
34
- @intents.each do |i|
48
+ @mutex.synchronize { @intents.dup }.each do |i|
35
49
  # Skip intents that require a specific context
36
50
  if i.context_requirement
37
51
  next unless context == i.context_requirement
@@ -47,12 +61,12 @@ module PatternRuby
47
61
  end
48
62
 
49
63
  def match_all(input, context: nil)
50
- input = input.to_s.strip
64
+ input = sanitize_input(input)
51
65
  return [] if input.empty?
52
66
 
53
67
  candidates = []
54
68
 
55
- @intents.each do |i|
69
+ @mutex.synchronize { @intents.dup }.each do |i|
56
70
  if i.context_requirement
57
71
  next unless context == i.context_requirement
58
72
  end
@@ -66,6 +80,12 @@ module PatternRuby
66
80
 
67
81
  private
68
82
 
83
+ def sanitize_input(input)
84
+ text = input.to_s.strip
85
+ text = text[0, MAX_INPUT_LENGTH] if text.length > MAX_INPUT_LENGTH
86
+ text
87
+ end
88
+
69
89
  def fallback_result(input)
70
90
  @fallback_handler&.call(input)
71
91
 
@@ -1,3 +1,3 @@
1
1
  module PatternRuby
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pattern-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo
@@ -50,6 +50,7 @@ files:
50
50
  - Rakefile
51
51
  - examples/chatbot.rb
52
52
  - examples/customer_service.rb
53
+ - examples/support_bot.rb
53
54
  - examples/with_llm_fallback.rb
54
55
  - lib/pattern_ruby.rb
55
56
  - lib/pattern_ruby/conversation.rb