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 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
@@ -0,0 +1,9 @@
1
+ require "rake/testtask"
2
+
3
+ Rake::TestTask.new(:test) do |t|
4
+ t.libs << "test" << "lib"
5
+ t.pattern = "test/test_*.rb"
6
+ t.warning = true
7
+ end
8
+
9
+ task default: :test
@@ -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
@@ -0,0 +1,3 @@
1
+ module PatternRuby
2
+ VERSION = "0.1.0"
3
+ end
@@ -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: []