pattern-ruby 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +191 -0
- data/Rakefile +9 -0
- data/examples/chatbot.rb +64 -0
- data/examples/customer_service.rb +53 -0
- data/examples/with_llm_fallback.rb +71 -0
- data/lib/pattern_ruby/conversation.rb +82 -0
- data/lib/pattern_ruby/entity.rb +17 -0
- data/lib/pattern_ruby/entity_types.rb +38 -0
- data/lib/pattern_ruby/intent.rb +144 -0
- data/lib/pattern_ruby/match_result.rb +29 -0
- data/lib/pattern_ruby/pattern_compiler.rb +171 -0
- data/lib/pattern_ruby/pipeline.rb +24 -0
- data/lib/pattern_ruby/rails/test_helpers.rb +23 -0
- data/lib/pattern_ruby/router.rb +79 -0
- data/lib/pattern_ruby/version.rb +3 -0
- data/lib/pattern_ruby.rb +15 -0
- data/pattern-ruby.gemspec +36 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 76b9f40922a19e84b9da9c7a0ccb8027b6a3d99f75782a248c8edd3111bf063f
|
|
4
|
+
data.tar.gz: b7d845a9dff5e5e479d3f74e5268d118da67cbe821450be6bb5e32586402a333
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: de646d6f5b0c9a8924a835986b55c4c33ae10da4b96040ce53e5ba6392ecf40520879d66f39f2655f88d84745a41ea7856f6d5a42b4653483eaa53080ce7aed9
|
|
7
|
+
data.tar.gz: 3fea24e0b08f2620e4b3da933a60d44abc9371283feab5648b2c8630f5fd69b41a0bf8e37c5c18065e678f491ee2f5f5c5cc2bc804c92437de46a412ad167512
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-03-07)
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- Router DSL with intent matching
|
|
7
|
+
- Pattern compiler: literals, entities, optionals, alternatives, wildcards
|
|
8
|
+
- Entity types: string, number, integer, email, phone, url, currency
|
|
9
|
+
- Custom entity type registration
|
|
10
|
+
- Regex pattern support
|
|
11
|
+
- Context-aware matching
|
|
12
|
+
- Multi-intent matching with scoring
|
|
13
|
+
- Pipeline for preprocessing chains
|
|
14
|
+
- Conversation state with slot filling
|
|
15
|
+
- Minitest test helpers
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johannes Dwi Cahyo
|
|
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,191 @@
|
|
|
1
|
+
# pattern-ruby
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
gem "pattern-ruby"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gem install pattern-ruby
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "pattern_ruby"
|
|
20
|
+
|
|
21
|
+
router = PatternRuby::Router.new do
|
|
22
|
+
intent :greeting do
|
|
23
|
+
pattern "hello"
|
|
24
|
+
pattern "hi"
|
|
25
|
+
pattern "good morning"
|
|
26
|
+
response "Hello! How can I help you?"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
intent :weather do
|
|
30
|
+
pattern "weather in {city}"
|
|
31
|
+
pattern "what's the weather in {city}"
|
|
32
|
+
entity :city, type: :string
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
fallback { |input| puts "Unknown: #{input}" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
result = router.match("weather in Tokyo")
|
|
39
|
+
result.intent # => :weather
|
|
40
|
+
result.entities # => { city: "Tokyo" }
|
|
41
|
+
result.matched? # => true
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Pattern Syntax
|
|
45
|
+
|
|
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
|
+
|
|
56
|
+
### Examples
|
|
57
|
+
|
|
58
|
+
```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
|
|
69
|
+
"book [a] flight from {origin} to {destination} [on {date}]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Entity Types
|
|
73
|
+
|
|
74
|
+
Built-in types: `:string`, `:number`, `:integer`, `:email`, `:phone`, `:url`, `:currency`
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# Register custom entity types
|
|
78
|
+
router = PatternRuby::Router.new do
|
|
79
|
+
entity_type :zipcode, pattern: /\d{5}/, parser: ->(s) { s.to_i }
|
|
80
|
+
|
|
81
|
+
intent :locate do
|
|
82
|
+
pattern "find stores near {zip}"
|
|
83
|
+
entity :zip, type: :zipcode
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
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"
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Regex Patterns
|
|
99
|
+
|
|
100
|
+
Use raw regex for complex matching:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
intent :order_status do
|
|
104
|
+
pattern(/order\s*#?\s*(?<order_id>\d{5,})/i)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
result = router.match("Where is order #12345?")
|
|
108
|
+
result.entities[:order_id] # => "12345"
|
|
109
|
+
```
|
|
110
|
+
|
|
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
|
+
## Context-Aware Matching
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
router = PatternRuby::Router.new do
|
|
124
|
+
intent :confirm do
|
|
125
|
+
pattern "yes"
|
|
126
|
+
context :awaiting_confirmation
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
intent :greeting do
|
|
130
|
+
pattern "yes" # only matches without context
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
router.match("yes") # => :greeting
|
|
135
|
+
router.match("yes", context: :awaiting_confirmation) # => :confirm
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Conversation State
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
convo = PatternRuby::Conversation.new(router)
|
|
142
|
+
|
|
143
|
+
result = convo.process("book a flight from NYC to London")
|
|
144
|
+
# => { origin: "NYC", destination: "London" }
|
|
145
|
+
|
|
146
|
+
result = convo.process("tomorrow")
|
|
147
|
+
# => fills missing date slot: { origin: "NYC", destination: "London", date: "tomorrow" }
|
|
148
|
+
|
|
149
|
+
convo.reset # clear all state
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Pipeline
|
|
153
|
+
|
|
154
|
+
Chain preprocessing steps before matching:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
pipeline = PatternRuby::Pipeline.new do
|
|
158
|
+
step(:normalize) { |input| input.strip.downcase.gsub(/[?!.]/, "") }
|
|
159
|
+
step(:correct) { |input| input.gsub(/wether/, "weather") }
|
|
160
|
+
step(:match) { |input| router.match(input) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
result = pipeline.process("Wether in Tokyo?")
|
|
164
|
+
# => intent: :weather, entities: { city: "tokyo" }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Test Helpers
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
require "pattern_ruby/rails/test_helpers"
|
|
171
|
+
|
|
172
|
+
class MyTest < Minitest::Test
|
|
173
|
+
include PatternRuby::TestHelpers
|
|
174
|
+
|
|
175
|
+
def test_greeting
|
|
176
|
+
assert_matches_intent :greeting, "hello", router: @router
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def test_entity
|
|
180
|
+
assert_extracts_entity :city, "Tokyo", "weather in Tokyo", router: @router
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def test_no_match
|
|
184
|
+
refute_matches "quantum physics", router: @router
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
data/Rakefile
ADDED
data/examples/chatbot.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "pattern_ruby"
|
|
2
|
+
|
|
3
|
+
router = PatternRuby::Router.new do
|
|
4
|
+
intent :greeting do
|
|
5
|
+
pattern "hello"
|
|
6
|
+
pattern "hi"
|
|
7
|
+
pattern "hey"
|
|
8
|
+
pattern "good morning"
|
|
9
|
+
response "Hello! How can I help you today?"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
intent :farewell do
|
|
13
|
+
pattern "goodbye"
|
|
14
|
+
pattern "bye"
|
|
15
|
+
pattern "see you"
|
|
16
|
+
response "Goodbye! Have a great day!"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
intent :weather do
|
|
20
|
+
pattern "weather in {city}"
|
|
21
|
+
pattern "what's the weather in {city}"
|
|
22
|
+
pattern "how's the weather in {city} {time:today|tomorrow|this week}"
|
|
23
|
+
entity :city, type: :string
|
|
24
|
+
entity :time, type: :string, default: "today"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
intent :help do
|
|
28
|
+
pattern "help"
|
|
29
|
+
pattern "what can you do"
|
|
30
|
+
response "I can help with weather, orders, and general questions."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
fallback do |input|
|
|
34
|
+
puts " [Fallback] Would route to LLM: #{input}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
puts "=== Chatbot Demo ==="
|
|
39
|
+
puts
|
|
40
|
+
|
|
41
|
+
inputs = [
|
|
42
|
+
"Hello",
|
|
43
|
+
"What's the weather in Tokyo",
|
|
44
|
+
"how's the weather in London tomorrow",
|
|
45
|
+
"help",
|
|
46
|
+
"Tell me a joke",
|
|
47
|
+
"bye"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
inputs.each do |input|
|
|
51
|
+
result = router.match(input)
|
|
52
|
+
print "User: #{input}\n"
|
|
53
|
+
|
|
54
|
+
if result.matched?
|
|
55
|
+
print " Intent: #{result.intent}"
|
|
56
|
+
print " | Entities: #{result.entities}" unless result.entities.empty?
|
|
57
|
+
print " | Score: #{result.score.round(2)}"
|
|
58
|
+
print "\n Response: #{result.response}" if result.response
|
|
59
|
+
puts
|
|
60
|
+
else
|
|
61
|
+
puts " [No match]"
|
|
62
|
+
end
|
|
63
|
+
puts
|
|
64
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "pattern_ruby"
|
|
2
|
+
|
|
3
|
+
router = PatternRuby::Router.new do
|
|
4
|
+
intent :order_status do
|
|
5
|
+
pattern "where is my order {order_id:\\d+}"
|
|
6
|
+
pattern "track order {order_id:\\d+}"
|
|
7
|
+
pattern(/order\s*#?\s*(?<order_id>\d{5,})/i)
|
|
8
|
+
entity :order_id, type: :string
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
intent :refund do
|
|
12
|
+
pattern "i want a refund"
|
|
13
|
+
pattern "refund my order {order_id:\\d+}"
|
|
14
|
+
pattern "can i get a refund"
|
|
15
|
+
entity :order_id, type: :string
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
intent :book_flight do
|
|
19
|
+
pattern "book [a] flight from {origin} to {destination} [on {date}]"
|
|
20
|
+
pattern "fly from {origin} to {destination}"
|
|
21
|
+
pattern "i want to (fly|travel|go) from {origin} to {destination}"
|
|
22
|
+
entity :origin, type: :string
|
|
23
|
+
entity :destination, type: :string
|
|
24
|
+
entity :date, type: :string
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
fallback { |_| }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Demonstrate conversation with slot filling
|
|
31
|
+
puts "=== Customer Service Conversation ==="
|
|
32
|
+
puts
|
|
33
|
+
|
|
34
|
+
convo = PatternRuby::Conversation.new(router)
|
|
35
|
+
|
|
36
|
+
messages = [
|
|
37
|
+
"book a flight from NYC to London",
|
|
38
|
+
"Friday",
|
|
39
|
+
"where is my order #55678",
|
|
40
|
+
"i want a refund"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
messages.each do |msg|
|
|
44
|
+
result = convo.process(msg)
|
|
45
|
+
puts "Customer: #{msg}"
|
|
46
|
+
if result.matched?
|
|
47
|
+
puts " Intent: #{result.intent}"
|
|
48
|
+
puts " Entities: #{result.entities}" unless result.entities.empty?
|
|
49
|
+
else
|
|
50
|
+
puts " [Route to agent]"
|
|
51
|
+
end
|
|
52
|
+
puts
|
|
53
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "pattern_ruby"
|
|
2
|
+
|
|
3
|
+
# This example shows how to use pattern-ruby as a first-pass filter
|
|
4
|
+
# before routing to an LLM for complex queries.
|
|
5
|
+
|
|
6
|
+
router = PatternRuby::Router.new do
|
|
7
|
+
intent :greeting do
|
|
8
|
+
pattern "hello"
|
|
9
|
+
pattern "hi"
|
|
10
|
+
pattern "hey"
|
|
11
|
+
response "Hello! How can I help you?"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
intent :hours do
|
|
15
|
+
pattern "what are your hours"
|
|
16
|
+
pattern "when are you open"
|
|
17
|
+
pattern "business hours"
|
|
18
|
+
response "We're open Monday-Friday, 9am-5pm EST."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
intent :pricing do
|
|
22
|
+
pattern "how much does {product} cost"
|
|
23
|
+
pattern "price of {product}"
|
|
24
|
+
pattern "pricing"
|
|
25
|
+
entity :product, type: :string
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
fallback { |_| }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
pipeline = PatternRuby::Pipeline.new do
|
|
32
|
+
step(:normalize) { |input| input.strip }
|
|
33
|
+
|
|
34
|
+
step(:match) do |input|
|
|
35
|
+
result = router.match(input)
|
|
36
|
+
|
|
37
|
+
if result.matched?
|
|
38
|
+
# Deterministic response — no LLM needed
|
|
39
|
+
puts " [Pattern Match] Intent: #{result.intent}"
|
|
40
|
+
if result.response
|
|
41
|
+
puts " Response: #{result.response}"
|
|
42
|
+
else
|
|
43
|
+
puts " Entities: #{result.entities}"
|
|
44
|
+
puts " [Would look up #{result.entities} in database]"
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
# Route to LLM
|
|
48
|
+
puts " [LLM Fallback] Sending to LLM: #{input}"
|
|
49
|
+
puts " [LLM would generate a contextual response here]"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
puts "=== Hybrid Pattern + LLM Demo ==="
|
|
57
|
+
puts
|
|
58
|
+
|
|
59
|
+
inputs = [
|
|
60
|
+
"hello",
|
|
61
|
+
"what are your hours",
|
|
62
|
+
"how much does the Pro plan cost",
|
|
63
|
+
"Can you explain the difference between your plans?",
|
|
64
|
+
"What's your refund policy for annual subscriptions?",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
inputs.each do |input|
|
|
68
|
+
puts "User: #{input}"
|
|
69
|
+
pipeline.process(input)
|
|
70
|
+
puts
|
|
71
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
class Conversation
|
|
3
|
+
attr_reader :context, :history, :slots
|
|
4
|
+
|
|
5
|
+
def initialize(router)
|
|
6
|
+
@router = router
|
|
7
|
+
@context = nil
|
|
8
|
+
@history = []
|
|
9
|
+
@slots = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def process(input)
|
|
13
|
+
result = @router.match(input, context: @context)
|
|
14
|
+
|
|
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
|
|
20
|
+
|
|
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
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@history << result
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def set_context(ctx)
|
|
53
|
+
@context = ctx
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def clear_context
|
|
57
|
+
@context = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reset
|
|
61
|
+
@context = nil
|
|
62
|
+
@history = []
|
|
63
|
+
@slots = {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def try_slot_fill(input, intent_name)
|
|
69
|
+
intent = @router.intents.find { |i| i.name == intent_name }
|
|
70
|
+
return false unless intent
|
|
71
|
+
|
|
72
|
+
# Find entity definitions that don't have a slot value yet
|
|
73
|
+
unfilled = intent.entity_definitions.select { |name, _| !@slots.key?(name) || @slots[name].nil? }
|
|
74
|
+
return false if unfilled.empty?
|
|
75
|
+
|
|
76
|
+
# Try the input as a value for the first unfilled slot
|
|
77
|
+
name, defn = unfilled.first
|
|
78
|
+
@slots[name] = input.strip
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
class Entity
|
|
3
|
+
attr_reader :name, :type, :value, :raw, :span
|
|
4
|
+
|
|
5
|
+
def initialize(name:, type: :string, value: nil, raw: nil, span: nil)
|
|
6
|
+
@name = name.to_sym
|
|
7
|
+
@type = type.to_sym
|
|
8
|
+
@value = value
|
|
9
|
+
@raw = raw
|
|
10
|
+
@span = span
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{ name: @name, type: @type, value: @value, raw: @raw }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
module EntityTypes
|
|
3
|
+
BUILT_IN = {
|
|
4
|
+
string: { pattern: nil, parser: nil },
|
|
5
|
+
number: { pattern: /\d+(?:\.\d+)?/, parser: ->(s) { s.include?(".") ? s.to_f : s.to_i } },
|
|
6
|
+
integer: { pattern: /\d+/, parser: ->(s) { s.to_i } },
|
|
7
|
+
email: { pattern: /[\w.+\-]+@[\w\-]+\.[\w.]+/, parser: nil },
|
|
8
|
+
phone: { pattern: /\+?\d[\d\s\-()]{7,}/, parser: nil },
|
|
9
|
+
url: { pattern: %r{https?://\S+}, parser: nil },
|
|
10
|
+
currency: { pattern: /\$[\d,]+(?:\.\d{2})?/, parser: ->(s) { s.delete(",").delete("$").to_f } },
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
class Registry
|
|
14
|
+
def initialize
|
|
15
|
+
@types = BUILT_IN.dup
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register(name, pattern: nil, parser: nil)
|
|
19
|
+
@types[name.to_sym] = { pattern: pattern, parser: parser }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get(name)
|
|
23
|
+
@types[name.to_sym]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pattern_for(name)
|
|
27
|
+
type = get(name)
|
|
28
|
+
type && type[:pattern]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse(name, raw_value)
|
|
32
|
+
type = get(name)
|
|
33
|
+
return raw_value unless type && type[:parser]
|
|
34
|
+
type[:parser].call(raw_value)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
class Intent
|
|
3
|
+
attr_reader :name, :compiled_patterns, :entity_definitions, :response_text, :context_requirement
|
|
4
|
+
|
|
5
|
+
def initialize(name, compiler:)
|
|
6
|
+
@name = name.to_sym
|
|
7
|
+
@compiler = compiler
|
|
8
|
+
@compiled_patterns = []
|
|
9
|
+
@entity_definitions = {}
|
|
10
|
+
@response_text = nil
|
|
11
|
+
@context_requirement = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pattern(pat)
|
|
15
|
+
if pat.is_a?(Regexp)
|
|
16
|
+
@compiled_patterns << RegexPattern.new(pat)
|
|
17
|
+
else
|
|
18
|
+
@compiled_patterns << @compiler.compile(pat)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def entity(name, type: :string, **options)
|
|
23
|
+
@entity_definitions[name.to_sym] = { type: type, **options }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def response(text)
|
|
27
|
+
@response_text = text
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def context(ctx)
|
|
31
|
+
@context_requirement = ctx
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def match(input, entity_registry: nil)
|
|
35
|
+
@compiled_patterns.each do |cp|
|
|
36
|
+
md = cp.match(input)
|
|
37
|
+
next unless md
|
|
38
|
+
|
|
39
|
+
entities = extract_entities(md, cp, entity_registry)
|
|
40
|
+
score = compute_score(cp, md, input)
|
|
41
|
+
|
|
42
|
+
return MatchResult.new(
|
|
43
|
+
intent: @name,
|
|
44
|
+
entities: entities,
|
|
45
|
+
pattern: cp.source,
|
|
46
|
+
score: score,
|
|
47
|
+
input: input,
|
|
48
|
+
response: @response_text
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def extract_entities(match_data, compiled_pattern, entity_registry)
|
|
57
|
+
entities = {}
|
|
58
|
+
|
|
59
|
+
if compiled_pattern.is_a?(RegexPattern)
|
|
60
|
+
match_data.named_captures.each do |name, value|
|
|
61
|
+
entities[name.to_sym] = parse_entity(name.to_sym, value, entity_registry)
|
|
62
|
+
end
|
|
63
|
+
# Also capture numbered groups if no named captures
|
|
64
|
+
if entities.empty? && match_data.captures.any?
|
|
65
|
+
match_data.captures.each_with_index do |cap, i|
|
|
66
|
+
entities[:"capture_#{i}"] = cap
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
compiled_pattern.entity_names.each do |ename|
|
|
71
|
+
raw = match_data[ename]
|
|
72
|
+
next unless raw
|
|
73
|
+
entities[ename] = parse_entity(ename, raw, entity_registry)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Apply defaults from entity definitions
|
|
78
|
+
@entity_definitions.each do |ename, defn|
|
|
79
|
+
next if entities.key?(ename)
|
|
80
|
+
entities[ename] = defn[:default] if defn.key?(:default)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
entities
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_entity(name, raw_value, entity_registry)
|
|
87
|
+
defn = @entity_definitions[name]
|
|
88
|
+
if defn && entity_registry
|
|
89
|
+
parsed = entity_registry.parse(defn[:type], raw_value)
|
|
90
|
+
return parsed if parsed
|
|
91
|
+
end
|
|
92
|
+
raw_value
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def compute_score(compiled_pattern, match_data, input)
|
|
96
|
+
if compiled_pattern.is_a?(RegexPattern)
|
|
97
|
+
matched_length = match_data[0].length.to_f
|
|
98
|
+
return (matched_length / input.length).clamp(0.0, 1.0)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
score = 0.0
|
|
102
|
+
|
|
103
|
+
# Literal ratio: more literal tokens = more specific
|
|
104
|
+
if compiled_pattern.token_count > 0
|
|
105
|
+
literal_ratio = compiled_pattern.literal_count.to_f / compiled_pattern.token_count
|
|
106
|
+
score += literal_ratio * 0.5
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Entity fill ratio
|
|
110
|
+
if compiled_pattern.entity_count > 0
|
|
111
|
+
filled = compiled_pattern.entity_names.count { |n| match_data[n] && !match_data[n].empty? }
|
|
112
|
+
entity_ratio = filled.to_f / compiled_pattern.entity_count
|
|
113
|
+
score += entity_ratio * 0.3
|
|
114
|
+
else
|
|
115
|
+
score += 0.3
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Coverage: how much of the input was consumed
|
|
119
|
+
matched_length = match_data[0].strip.length.to_f
|
|
120
|
+
coverage = input.strip.empty? ? 0.0 : matched_length / input.strip.length
|
|
121
|
+
score += coverage * 0.2
|
|
122
|
+
|
|
123
|
+
score.clamp(0.0, 1.0)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Wraps a raw Regexp as a pattern
|
|
128
|
+
class RegexPattern
|
|
129
|
+
attr_reader :source, :regex, :entity_names, :literal_count, :token_count, :entity_count
|
|
130
|
+
|
|
131
|
+
def initialize(regex)
|
|
132
|
+
@source = regex.source
|
|
133
|
+
@regex = regex
|
|
134
|
+
@entity_names = regex.named_captures.keys.map(&:to_sym)
|
|
135
|
+
@literal_count = 0
|
|
136
|
+
@token_count = 1
|
|
137
|
+
@entity_count = @entity_names.size
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def match(input)
|
|
141
|
+
@regex.match(input)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
class MatchResult
|
|
3
|
+
attr_reader :intent, :entities, :pattern, :score, :input, :metadata, :response
|
|
4
|
+
|
|
5
|
+
def initialize(intent: nil, entities: {}, pattern: nil, score: 0.0,
|
|
6
|
+
input: nil, metadata: {}, response: nil, fallback: false)
|
|
7
|
+
@intent = intent
|
|
8
|
+
@entities = entities
|
|
9
|
+
@pattern = pattern
|
|
10
|
+
@score = score
|
|
11
|
+
@input = input
|
|
12
|
+
@metadata = metadata
|
|
13
|
+
@response = response
|
|
14
|
+
@fallback = fallback
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def matched?
|
|
18
|
+
@intent != nil && !fallback?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fallback?
|
|
22
|
+
@fallback
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{ intent: @intent, entities: @entities, score: @score, pattern: @pattern }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
class CompiledPattern
|
|
3
|
+
attr_reader :source, :regex, :entity_names, :literal_count, :token_count, :entity_count
|
|
4
|
+
|
|
5
|
+
def initialize(source:, regex:, entity_names:, literal_count:, token_count:, entity_count:)
|
|
6
|
+
@source = source
|
|
7
|
+
@regex = regex
|
|
8
|
+
@entity_names = entity_names
|
|
9
|
+
@literal_count = literal_count
|
|
10
|
+
@token_count = token_count
|
|
11
|
+
@entity_count = entity_count
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def match(input)
|
|
15
|
+
@regex.match(input)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class PatternCompiler
|
|
20
|
+
def initialize(entity_registry: nil)
|
|
21
|
+
@entity_registry = entity_registry
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def compile(pattern_string)
|
|
25
|
+
tokens = tokenize(pattern_string)
|
|
26
|
+
entity_names = []
|
|
27
|
+
literal_count = 0
|
|
28
|
+
regex_parts = []
|
|
29
|
+
|
|
30
|
+
tokens.each do |token|
|
|
31
|
+
case token
|
|
32
|
+
when EntityToken
|
|
33
|
+
entity_names << token.name.to_sym
|
|
34
|
+
regex_parts << build_entity_regex(token)
|
|
35
|
+
when OptionalToken
|
|
36
|
+
inner = compile_inner(token.content)
|
|
37
|
+
entity_names.concat(inner[:entity_names])
|
|
38
|
+
regex_parts << "(?:\\s+#{inner[:regex]})?"
|
|
39
|
+
when AlternationToken
|
|
40
|
+
alts = token.alternatives.map { |a| Regexp.escape(a) }
|
|
41
|
+
regex_parts << "(?:#{alts.join('|')})"
|
|
42
|
+
literal_count += 1
|
|
43
|
+
when WildcardToken
|
|
44
|
+
regex_parts << "(.+)"
|
|
45
|
+
when LiteralToken
|
|
46
|
+
regex_parts << Regexp.escape(token.text)
|
|
47
|
+
literal_count += 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
token_count = tokens.size
|
|
52
|
+
entity_count = entity_names.size
|
|
53
|
+
|
|
54
|
+
# Join with \s+ but make whitespace before optional groups flexible
|
|
55
|
+
regex_str = +""
|
|
56
|
+
regex_parts.each_with_index do |part, i|
|
|
57
|
+
if i > 0 && !part.start_with?("(?:\\s+") # optional groups already include leading \s+
|
|
58
|
+
regex_str << "\\s+"
|
|
59
|
+
end
|
|
60
|
+
regex_str << part
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
regex = Regexp.new("\\A\\s*#{regex_str}\\s*\\z", Regexp::IGNORECASE)
|
|
64
|
+
|
|
65
|
+
CompiledPattern.new(
|
|
66
|
+
source: pattern_string,
|
|
67
|
+
regex: regex,
|
|
68
|
+
entity_names: entity_names,
|
|
69
|
+
literal_count: literal_count,
|
|
70
|
+
token_count: token_count,
|
|
71
|
+
entity_count: entity_count
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
EntityToken = Struct.new(:name, :constraint)
|
|
78
|
+
OptionalToken = Struct.new(:content)
|
|
79
|
+
AlternationToken = Struct.new(:alternatives)
|
|
80
|
+
WildcardToken = Class.new
|
|
81
|
+
LiteralToken = Struct.new(:text)
|
|
82
|
+
|
|
83
|
+
def tokenize(pattern_string)
|
|
84
|
+
tokens = []
|
|
85
|
+
scanner = StringScanner.new(pattern_string.strip)
|
|
86
|
+
|
|
87
|
+
until scanner.eos?
|
|
88
|
+
scanner.skip(/\s+/)
|
|
89
|
+
break if scanner.eos?
|
|
90
|
+
|
|
91
|
+
if scanner.scan(/\{([^}]+)\}/)
|
|
92
|
+
# Entity: {name} or {name:constraint}
|
|
93
|
+
content = scanner[1]
|
|
94
|
+
name, constraint = content.split(":", 2)
|
|
95
|
+
tokens << EntityToken.new(name.strip, constraint&.strip)
|
|
96
|
+
elsif scanner.scan(/\[/)
|
|
97
|
+
# Optional: [content]
|
|
98
|
+
depth = 1
|
|
99
|
+
content = +""
|
|
100
|
+
until depth == 0 || scanner.eos?
|
|
101
|
+
if scanner.scan(/\[/)
|
|
102
|
+
depth += 1
|
|
103
|
+
content << "["
|
|
104
|
+
elsif scanner.scan(/\]/)
|
|
105
|
+
depth -= 1
|
|
106
|
+
content << "]" if depth > 0
|
|
107
|
+
else
|
|
108
|
+
content << scanner.getch
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
tokens << OptionalToken.new(content.strip)
|
|
112
|
+
elsif scanner.scan(/\(([^)]+)\)/)
|
|
113
|
+
# Alternation: (a|b|c)
|
|
114
|
+
alts = scanner[1].split("|").map(&:strip)
|
|
115
|
+
tokens << AlternationToken.new(alts)
|
|
116
|
+
elsif scanner.scan(/\*/)
|
|
117
|
+
tokens << WildcardToken.new
|
|
118
|
+
else
|
|
119
|
+
# Literal word(s) — scan until next special token or whitespace
|
|
120
|
+
word = scanner.scan(/[^\s\[\]{}()*]+/)
|
|
121
|
+
tokens << LiteralToken.new(word) if word
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
tokens
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_entity_regex(token)
|
|
129
|
+
if token.constraint
|
|
130
|
+
if token.constraint.include?("|")
|
|
131
|
+
# Enum constraint: {time:today|tomorrow|this week}
|
|
132
|
+
alts = token.constraint.split("|").map { |a| Regexp.escape(a.strip) }
|
|
133
|
+
"(?<#{token.name}>#{alts.join('|')})"
|
|
134
|
+
else
|
|
135
|
+
# Regex constraint: {order_id:\\d+}
|
|
136
|
+
"(?<#{token.name}>#{token.constraint})"
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
# Check entity registry for type-specific pattern
|
|
140
|
+
type_pattern = @entity_registry&.pattern_for(token.name)
|
|
141
|
+
if type_pattern
|
|
142
|
+
"(?<#{token.name}>#{type_pattern.source})"
|
|
143
|
+
else
|
|
144
|
+
# Default: capture any non-greedy text
|
|
145
|
+
"(?<#{token.name}>.+?)"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def compile_inner(content)
|
|
151
|
+
inner_tokens = tokenize(content)
|
|
152
|
+
entity_names = []
|
|
153
|
+
parts = []
|
|
154
|
+
|
|
155
|
+
inner_tokens.each do |token|
|
|
156
|
+
case token
|
|
157
|
+
when EntityToken
|
|
158
|
+
entity_names << token.name.to_sym
|
|
159
|
+
parts << build_entity_regex(token)
|
|
160
|
+
when LiteralToken
|
|
161
|
+
parts << Regexp.escape(token.text)
|
|
162
|
+
when AlternationToken
|
|
163
|
+
alts = token.alternatives.map { |a| Regexp.escape(a) }
|
|
164
|
+
parts << "(?:#{alts.join('|')})"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
{ regex: parts.join("\\s+"), entity_names: entity_names }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
class Pipeline
|
|
3
|
+
def initialize(&block)
|
|
4
|
+
@steps = []
|
|
5
|
+
instance_eval(&block) if block
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def step(name, &block)
|
|
9
|
+
@steps << { name: name, handler: block }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def process(input)
|
|
13
|
+
result = input
|
|
14
|
+
@steps.each do |s|
|
|
15
|
+
result = s[:handler].call(result)
|
|
16
|
+
end
|
|
17
|
+
result
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def steps
|
|
21
|
+
@steps.map { |s| s[:name] }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
module TestHelpers
|
|
3
|
+
def assert_matches_intent(expected_intent, input, router:, message: nil)
|
|
4
|
+
result = router.match(input)
|
|
5
|
+
msg = message || "Expected input #{input.inspect} to match intent :#{expected_intent}, but got :#{result.intent}"
|
|
6
|
+
assert result.matched?, msg
|
|
7
|
+
assert_equal expected_intent, result.intent, msg
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def assert_extracts_entity(entity_name, expected_value, input, router:, message: nil)
|
|
11
|
+
result = router.match(input)
|
|
12
|
+
msg = message || "Expected entity :#{entity_name} to be #{expected_value.inspect}"
|
|
13
|
+
assert result.matched?, "Input #{input.inspect} did not match any intent"
|
|
14
|
+
assert_equal expected_value, result.entities[entity_name.to_sym], msg
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def refute_matches(input, router:, message: nil)
|
|
18
|
+
result = router.match(input)
|
|
19
|
+
msg = message || "Expected input #{input.inspect} not to match, but matched :#{result.intent}"
|
|
20
|
+
refute result.matched?, msg
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module PatternRuby
|
|
2
|
+
class Router
|
|
3
|
+
attr_reader :intents
|
|
4
|
+
|
|
5
|
+
def initialize(&block)
|
|
6
|
+
@intents = []
|
|
7
|
+
@fallback_handler = nil
|
|
8
|
+
@entity_registry = EntityTypes::Registry.new
|
|
9
|
+
@compiler = PatternCompiler.new(entity_registry: @entity_registry)
|
|
10
|
+
instance_eval(&block) if block
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def intent(name, &block)
|
|
14
|
+
i = Intent.new(name, compiler: @compiler)
|
|
15
|
+
i.instance_eval(&block) if block
|
|
16
|
+
@intents << i
|
|
17
|
+
i
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fallback(&block)
|
|
21
|
+
@fallback_handler = block
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def entity_type(name, **options)
|
|
25
|
+
@entity_registry.register(name, **options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def match(input, context: nil)
|
|
29
|
+
input = input.to_s.strip
|
|
30
|
+
return fallback_result(input) if input.empty?
|
|
31
|
+
|
|
32
|
+
candidates = []
|
|
33
|
+
|
|
34
|
+
@intents.each do |i|
|
|
35
|
+
# Skip intents that require a specific context
|
|
36
|
+
if i.context_requirement
|
|
37
|
+
next unless context == i.context_requirement
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
result = i.match(input, entity_registry: @entity_registry)
|
|
41
|
+
candidates << result if result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return candidates.max_by(&:score) unless candidates.empty?
|
|
45
|
+
|
|
46
|
+
fallback_result(input)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def match_all(input, context: nil)
|
|
50
|
+
input = input.to_s.strip
|
|
51
|
+
return [] if input.empty?
|
|
52
|
+
|
|
53
|
+
candidates = []
|
|
54
|
+
|
|
55
|
+
@intents.each do |i|
|
|
56
|
+
if i.context_requirement
|
|
57
|
+
next unless context == i.context_requirement
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result = i.match(input, entity_registry: @entity_registry)
|
|
61
|
+
candidates << result if result
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
candidates.sort_by { |r| -r.score }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def fallback_result(input)
|
|
70
|
+
@fallback_handler&.call(input)
|
|
71
|
+
|
|
72
|
+
MatchResult.new(
|
|
73
|
+
intent: nil,
|
|
74
|
+
input: input,
|
|
75
|
+
fallback: true
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/pattern_ruby.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "strscan"
|
|
2
|
+
|
|
3
|
+
require_relative "pattern_ruby/version"
|
|
4
|
+
require_relative "pattern_ruby/match_result"
|
|
5
|
+
require_relative "pattern_ruby/entity"
|
|
6
|
+
require_relative "pattern_ruby/entity_types"
|
|
7
|
+
require_relative "pattern_ruby/pattern_compiler"
|
|
8
|
+
require_relative "pattern_ruby/intent"
|
|
9
|
+
require_relative "pattern_ruby/router"
|
|
10
|
+
require_relative "pattern_ruby/pipeline"
|
|
11
|
+
require_relative "pattern_ruby/conversation"
|
|
12
|
+
|
|
13
|
+
module PatternRuby
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative "lib/pattern_ruby/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "pattern-ruby"
|
|
5
|
+
spec.version = PatternRuby::VERSION
|
|
6
|
+
spec.authors = ["Johannes Dwi Cahyo"]
|
|
7
|
+
spec.summary = "Deterministic pattern detection and intent matching engine for Ruby"
|
|
8
|
+
spec.description = "A rule-based pattern matching engine for chatbots and conversational AI. " \
|
|
9
|
+
"Provides a DSL for defining intents, extracting entities, and routing " \
|
|
10
|
+
"user input — deterministically, with zero latency and no API calls."
|
|
11
|
+
spec.homepage = "https://github.com/johannesdwicahyo/pattern-ruby"
|
|
12
|
+
spec.license = "MIT"
|
|
13
|
+
|
|
14
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
15
|
+
|
|
16
|
+
spec.files = Dir[
|
|
17
|
+
"lib/**/*.rb",
|
|
18
|
+
"examples/**/*.rb",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"Rakefile",
|
|
23
|
+
"pattern-ruby.gemspec"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
spec.metadata = {
|
|
27
|
+
"homepage_uri" => spec.homepage,
|
|
28
|
+
"source_code_uri" => spec.homepage,
|
|
29
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
35
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
36
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pattern-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Johannes Dwi Cahyo
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
description: A rule-based pattern matching engine for chatbots and conversational
|
|
41
|
+
AI. Provides a DSL for defining intents, extracting entities, and routing user input
|
|
42
|
+
— deterministically, with zero latency and no API calls.
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- CHANGELOG.md
|
|
48
|
+
- LICENSE
|
|
49
|
+
- README.md
|
|
50
|
+
- Rakefile
|
|
51
|
+
- examples/chatbot.rb
|
|
52
|
+
- examples/customer_service.rb
|
|
53
|
+
- examples/with_llm_fallback.rb
|
|
54
|
+
- lib/pattern_ruby.rb
|
|
55
|
+
- lib/pattern_ruby/conversation.rb
|
|
56
|
+
- lib/pattern_ruby/entity.rb
|
|
57
|
+
- lib/pattern_ruby/entity_types.rb
|
|
58
|
+
- lib/pattern_ruby/intent.rb
|
|
59
|
+
- lib/pattern_ruby/match_result.rb
|
|
60
|
+
- lib/pattern_ruby/pattern_compiler.rb
|
|
61
|
+
- lib/pattern_ruby/pipeline.rb
|
|
62
|
+
- lib/pattern_ruby/rails/test_helpers.rb
|
|
63
|
+
- lib/pattern_ruby/router.rb
|
|
64
|
+
- lib/pattern_ruby/version.rb
|
|
65
|
+
- pattern-ruby.gemspec
|
|
66
|
+
homepage: https://github.com/johannesdwicahyo/pattern-ruby
|
|
67
|
+
licenses:
|
|
68
|
+
- MIT
|
|
69
|
+
metadata:
|
|
70
|
+
homepage_uri: https://github.com/johannesdwicahyo/pattern-ruby
|
|
71
|
+
source_code_uri: https://github.com/johannesdwicahyo/pattern-ruby
|
|
72
|
+
changelog_uri: https://github.com/johannesdwicahyo/pattern-ruby/blob/main/CHANGELOG.md
|
|
73
|
+
rdoc_options: []
|
|
74
|
+
require_paths:
|
|
75
|
+
- lib
|
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: 3.1.0
|
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '0'
|
|
86
|
+
requirements: []
|
|
87
|
+
rubygems_version: 3.6.9
|
|
88
|
+
specification_version: 4
|
|
89
|
+
summary: Deterministic pattern detection and intent matching engine for Ruby
|
|
90
|
+
test_files: []
|