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 +4 -4
- data/README.md +116 -55
- data/examples/support_bot.rb +252 -0
- data/lib/pattern_ruby/conversation.rb +43 -38
- data/lib/pattern_ruby/entity_types.rb +29 -2
- data/lib/pattern_ruby/intent.rb +4 -1
- data/lib/pattern_ruby/pattern_compiler.rb +12 -0
- data/lib/pattern_ruby/router.rb +25 -5
- data/lib/pattern_ruby/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f811da9c8851dd1ac42ed24ab7db8b93a05bcefd61d9c76a66676e3c97cbdf15
|
|
4
|
+
data.tar.gz: 3b282051dfbd99a38a5960d6ce46e7637b5ba63a1c2d9bbcae908ba58af7e558
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
{name
|
|
50
|
-
{name
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
###
|
|
65
|
+
### Combined Example
|
|
57
66
|
|
|
58
67
|
```ruby
|
|
59
|
-
#
|
|
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
|
|
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 "
|
|
86
|
+
pattern "stores near {zip}"
|
|
83
87
|
entity :zip, type: :zipcode
|
|
84
88
|
end
|
|
85
|
-
end
|
|
86
|
-
```
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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 :
|
|
130
|
-
pattern "yes" #
|
|
123
|
+
intent :affirmative_greeting do
|
|
124
|
+
pattern "yes" # matches when no context
|
|
131
125
|
end
|
|
132
126
|
end
|
|
133
127
|
|
|
134
|
-
router.match("yes") # => :
|
|
128
|
+
router.match("yes") # => :affirmative_greeting
|
|
135
129
|
router.match("yes", context: :awaiting_confirmation) # => :confirm
|
|
136
130
|
```
|
|
137
131
|
|
|
138
|
-
##
|
|
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("
|
|
147
|
-
# => fills missing
|
|
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
|
-
#
|
|
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
|
|
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
|
|
240
|
+
def test_entity_extraction
|
|
180
241
|
assert_extracts_entity :city, "Tokyo", "weather in Tokyo", router: @router
|
|
181
242
|
end
|
|
182
243
|
|
|
183
|
-
def
|
|
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
|
-
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
result = @router.match(input, context: @context)
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
@
|
|
62
|
-
|
|
63
|
-
|
|
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,
|
|
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
|
-
@
|
|
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)
|
data/lib/pattern_ruby/intent.rb
CHANGED
|
@@ -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
|
-
|
|
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})"
|
data/lib/pattern_ruby/router.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
|
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
|
|
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
|
|
data/lib/pattern_ruby/version.rb
CHANGED
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.
|
|
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
|