kbs 0.0.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 +7 -0
- data/.envrc +3 -0
- data/CHANGELOG.md +5 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/Rakefile +8 -0
- data/examples/README.md +531 -0
- data/examples/advanced_example.rb +270 -0
- data/examples/ai_enhanced_kbs.rb +523 -0
- data/examples/blackboard_demo.rb +50 -0
- data/examples/car_diagnostic.rb +64 -0
- data/examples/concurrent_inference_demo.rb +363 -0
- data/examples/csv_trading_system.rb +559 -0
- data/examples/iot_demo_using_dsl.rb +83 -0
- data/examples/portfolio_rebalancing_system.rb +651 -0
- data/examples/redis_trading_demo.rb +177 -0
- data/examples/sample_stock_data.csv +46 -0
- data/examples/stock_trading_advanced.rb +469 -0
- data/examples/stock_trading_system.rb.bak +563 -0
- data/examples/timestamped_trading.rb +286 -0
- data/examples/trading_demo.rb +334 -0
- data/examples/working_demo.rb +176 -0
- data/lib/kbs/alpha_memory.rb +37 -0
- data/lib/kbs/beta_memory.rb +57 -0
- data/lib/kbs/blackboard/audit_log.rb +115 -0
- data/lib/kbs/blackboard/engine.rb +83 -0
- data/lib/kbs/blackboard/fact.rb +65 -0
- data/lib/kbs/blackboard/memory.rb +191 -0
- data/lib/kbs/blackboard/message_queue.rb +96 -0
- data/lib/kbs/blackboard/persistence/hybrid_store.rb +118 -0
- data/lib/kbs/blackboard/persistence/redis_store.rb +218 -0
- data/lib/kbs/blackboard/persistence/sqlite_store.rb +242 -0
- data/lib/kbs/blackboard/persistence/store.rb +55 -0
- data/lib/kbs/blackboard/redis_audit_log.rb +107 -0
- data/lib/kbs/blackboard/redis_message_queue.rb +111 -0
- data/lib/kbs/blackboard.rb +23 -0
- data/lib/kbs/condition.rb +26 -0
- data/lib/kbs/dsl/condition_helpers.rb +57 -0
- data/lib/kbs/dsl/knowledge_base.rb +86 -0
- data/lib/kbs/dsl/pattern_evaluator.rb +69 -0
- data/lib/kbs/dsl/rule_builder.rb +115 -0
- data/lib/kbs/dsl/variable.rb +35 -0
- data/lib/kbs/dsl.rb +18 -0
- data/lib/kbs/fact.rb +43 -0
- data/lib/kbs/join_node.rb +117 -0
- data/lib/kbs/negation_node.rb +88 -0
- data/lib/kbs/production_node.rb +28 -0
- data/lib/kbs/rete_engine.rb +108 -0
- data/lib/kbs/rule.rb +46 -0
- data/lib/kbs/token.rb +37 -0
- data/lib/kbs/version.rb +5 -0
- data/lib/kbs/working_memory.rb +32 -0
- data/lib/kbs.rb +20 -0
- metadata +164 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sqlite3'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'store'
|
|
6
|
+
|
|
7
|
+
module KBS
|
|
8
|
+
module Blackboard
|
|
9
|
+
module Persistence
|
|
10
|
+
class SqliteStore < Store
|
|
11
|
+
attr_reader :db, :db_path, :session_id
|
|
12
|
+
|
|
13
|
+
def initialize(db_path: ':memory:', session_id: nil)
|
|
14
|
+
@db_path = db_path
|
|
15
|
+
@session_id = session_id
|
|
16
|
+
@transaction_depth = 0
|
|
17
|
+
setup_database
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def setup_database
|
|
21
|
+
@db = SQLite3::Database.new(@db_path)
|
|
22
|
+
@db.results_as_hash = true
|
|
23
|
+
create_tables
|
|
24
|
+
create_indexes
|
|
25
|
+
setup_triggers
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_tables
|
|
29
|
+
@db.execute_batch <<-SQL
|
|
30
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
uuid TEXT UNIQUE NOT NULL,
|
|
33
|
+
fact_type TEXT NOT NULL,
|
|
34
|
+
attributes TEXT NOT NULL,
|
|
35
|
+
fact_timestamp TIMESTAMP,
|
|
36
|
+
market_timestamp TIMESTAMP,
|
|
37
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
38
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
39
|
+
session_id TEXT,
|
|
40
|
+
retracted BOOLEAN DEFAULT 0,
|
|
41
|
+
retracted_at TIMESTAMP,
|
|
42
|
+
data_source TEXT,
|
|
43
|
+
market_session TEXT
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS knowledge_sources (
|
|
47
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
48
|
+
name TEXT UNIQUE NOT NULL,
|
|
49
|
+
description TEXT,
|
|
50
|
+
topics TEXT,
|
|
51
|
+
active BOOLEAN DEFAULT 1,
|
|
52
|
+
registered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
53
|
+
);
|
|
54
|
+
SQL
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def create_indexes
|
|
58
|
+
@db.execute_batch <<-SQL
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_facts_type ON facts(fact_type);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_facts_session ON facts(session_id);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_facts_retracted ON facts(retracted);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_facts_timestamp ON facts(fact_timestamp);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_facts_market_timestamp ON facts(market_timestamp);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_facts_market_session ON facts(market_session);
|
|
65
|
+
SQL
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def setup_triggers
|
|
69
|
+
@db.execute_batch <<-SQL
|
|
70
|
+
CREATE TRIGGER IF NOT EXISTS update_fact_timestamp
|
|
71
|
+
AFTER UPDATE ON facts
|
|
72
|
+
BEGIN
|
|
73
|
+
UPDATE facts SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
|
74
|
+
END;
|
|
75
|
+
SQL
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def add_fact(uuid, type, attributes)
|
|
79
|
+
attributes_json = JSON.generate(attributes)
|
|
80
|
+
|
|
81
|
+
@db.execute(
|
|
82
|
+
"INSERT INTO facts (uuid, fact_type, attributes, session_id) VALUES (?, ?, ?, ?)",
|
|
83
|
+
[uuid, type.to_s, attributes_json, @session_id]
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def remove_fact(uuid)
|
|
88
|
+
result = @db.get_first_row(
|
|
89
|
+
"SELECT fact_type, attributes FROM facts WHERE uuid = ? AND retracted = 0",
|
|
90
|
+
[uuid]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if result
|
|
94
|
+
@db.execute(
|
|
95
|
+
"UPDATE facts SET retracted = 1, retracted_at = CURRENT_TIMESTAMP WHERE uuid = ?",
|
|
96
|
+
[uuid]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
type: result['fact_type'].to_sym,
|
|
101
|
+
attributes: JSON.parse(result['attributes'], symbolize_names: true)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def update_fact(uuid, attributes)
|
|
107
|
+
attributes_json = JSON.generate(attributes)
|
|
108
|
+
|
|
109
|
+
@db.execute(
|
|
110
|
+
"UPDATE facts SET attributes = ? WHERE uuid = ? AND retracted = 0",
|
|
111
|
+
[attributes_json, uuid]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
get_fact_type(uuid)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def get_fact(uuid)
|
|
118
|
+
result = @db.get_first_row(
|
|
119
|
+
"SELECT * FROM facts WHERE uuid = ? AND retracted = 0",
|
|
120
|
+
[uuid]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if result
|
|
124
|
+
{
|
|
125
|
+
uuid: result['uuid'],
|
|
126
|
+
type: result['fact_type'].to_sym,
|
|
127
|
+
attributes: JSON.parse(result['attributes'], symbolize_names: true)
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def get_facts(type = nil, pattern = {})
|
|
133
|
+
query = "SELECT * FROM facts WHERE retracted = 0"
|
|
134
|
+
params = []
|
|
135
|
+
|
|
136
|
+
if type
|
|
137
|
+
query += " AND fact_type = ?"
|
|
138
|
+
params << type.to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
results = @db.execute(query, params)
|
|
142
|
+
|
|
143
|
+
results.map do |row|
|
|
144
|
+
attributes = JSON.parse(row['attributes'], symbolize_names: true)
|
|
145
|
+
|
|
146
|
+
if matches_pattern?(attributes, pattern)
|
|
147
|
+
{
|
|
148
|
+
uuid: row['uuid'],
|
|
149
|
+
type: row['fact_type'].to_sym,
|
|
150
|
+
attributes: attributes
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end.compact
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def query_facts(sql_conditions = nil, params = [])
|
|
157
|
+
query = "SELECT * FROM facts WHERE retracted = 0"
|
|
158
|
+
query += " AND #{sql_conditions}" if sql_conditions
|
|
159
|
+
|
|
160
|
+
results = @db.execute(query, params)
|
|
161
|
+
|
|
162
|
+
results.map do |row|
|
|
163
|
+
{
|
|
164
|
+
uuid: row['uuid'],
|
|
165
|
+
type: row['fact_type'].to_sym,
|
|
166
|
+
attributes: JSON.parse(row['attributes'], symbolize_names: true)
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def register_knowledge_source(name, description: nil, topics: [])
|
|
172
|
+
topics_json = JSON.generate(topics)
|
|
173
|
+
|
|
174
|
+
@db.execute(
|
|
175
|
+
"INSERT OR REPLACE INTO knowledge_sources (name, description, topics) VALUES (?, ?, ?)",
|
|
176
|
+
[name, description, topics_json]
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def clear_session(session_id)
|
|
181
|
+
@db.execute(
|
|
182
|
+
"UPDATE facts SET retracted = 1, retracted_at = CURRENT_TIMESTAMP WHERE session_id = ?",
|
|
183
|
+
[session_id]
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def vacuum
|
|
188
|
+
@db.execute("VACUUM")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def stats
|
|
192
|
+
{
|
|
193
|
+
total_facts: @db.get_first_value("SELECT COUNT(*) FROM facts"),
|
|
194
|
+
active_facts: @db.get_first_value("SELECT COUNT(*) FROM facts WHERE retracted = 0"),
|
|
195
|
+
knowledge_sources: @db.get_first_value("SELECT COUNT(*) FROM knowledge_sources WHERE active = 1")
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def transaction(&block)
|
|
200
|
+
@transaction_depth += 1
|
|
201
|
+
result = nil
|
|
202
|
+
begin
|
|
203
|
+
if @transaction_depth == 1
|
|
204
|
+
@db.transaction do
|
|
205
|
+
result = yield
|
|
206
|
+
end
|
|
207
|
+
else
|
|
208
|
+
result = yield
|
|
209
|
+
end
|
|
210
|
+
ensure
|
|
211
|
+
@transaction_depth -= 1
|
|
212
|
+
end
|
|
213
|
+
result
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def close
|
|
217
|
+
@db.close if @db
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
def get_fact_type(uuid)
|
|
223
|
+
result = @db.get_first_row(
|
|
224
|
+
"SELECT fact_type FROM facts WHERE uuid = ?",
|
|
225
|
+
[uuid]
|
|
226
|
+
)
|
|
227
|
+
result ? result['fact_type'].to_sym : nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def matches_pattern?(attributes, pattern)
|
|
231
|
+
pattern.all? do |key, value|
|
|
232
|
+
if value.is_a?(Proc)
|
|
233
|
+
attributes[key] && value.call(attributes[key])
|
|
234
|
+
else
|
|
235
|
+
attributes[key] == value
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KBS
|
|
4
|
+
module Blackboard
|
|
5
|
+
module Persistence
|
|
6
|
+
# Abstract interface for fact persistence
|
|
7
|
+
class Store
|
|
8
|
+
def add_fact(uuid, type, attributes)
|
|
9
|
+
raise NotImplementedError, "#{self.class} must implement #add_fact"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def remove_fact(uuid)
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #remove_fact"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def update_fact(uuid, attributes)
|
|
17
|
+
raise NotImplementedError, "#{self.class} must implement #update_fact"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get_fact(uuid)
|
|
21
|
+
raise NotImplementedError, "#{self.class} must implement #get_fact"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get_facts(type = nil, pattern = {})
|
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #get_facts"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def query_facts(conditions = nil, params = [])
|
|
29
|
+
raise NotImplementedError, "#{self.class} must implement #query_facts"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def clear_session(session_id)
|
|
33
|
+
raise NotImplementedError, "#{self.class} must implement #clear_session"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stats
|
|
37
|
+
raise NotImplementedError, "#{self.class} must implement #stats"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def close
|
|
41
|
+
raise NotImplementedError, "#{self.class} must implement #close"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def vacuum
|
|
45
|
+
# Optional operation - no-op by default
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def transaction(&block)
|
|
49
|
+
# Default: just execute the block
|
|
50
|
+
yield if block_given?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module KBS
|
|
7
|
+
module Blackboard
|
|
8
|
+
# Redis-based audit log using lists for history
|
|
9
|
+
class RedisAuditLog
|
|
10
|
+
def initialize(redis, session_id)
|
|
11
|
+
@redis = redis
|
|
12
|
+
@session_id = session_id
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def log_fact_change(fact_uuid, fact_type, attributes, action)
|
|
16
|
+
attributes_json = attributes.is_a?(String) ? attributes : JSON.generate(attributes)
|
|
17
|
+
timestamp = Time.now.to_f
|
|
18
|
+
|
|
19
|
+
entry = {
|
|
20
|
+
'fact_uuid' => fact_uuid,
|
|
21
|
+
'fact_type' => fact_type.to_s,
|
|
22
|
+
'attributes' => attributes_json,
|
|
23
|
+
'action' => action,
|
|
24
|
+
'timestamp' => timestamp,
|
|
25
|
+
'session_id' => @session_id
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
entry_json = JSON.generate(entry)
|
|
29
|
+
|
|
30
|
+
# Add to global history (as list - newest first)
|
|
31
|
+
@redis.lpush('fact_history:all', entry_json)
|
|
32
|
+
|
|
33
|
+
# Add to fact-specific history
|
|
34
|
+
@redis.lpush("fact_history:#{fact_uuid}", entry_json)
|
|
35
|
+
|
|
36
|
+
# Optionally limit history size (e.g., keep last 10000 entries)
|
|
37
|
+
@redis.ltrim('fact_history:all', 0, 9999)
|
|
38
|
+
@redis.ltrim("fact_history:#{fact_uuid}", 0, 999)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def log_rule_firing(rule_name, fact_uuids, bindings = {})
|
|
42
|
+
timestamp = Time.now.to_f
|
|
43
|
+
|
|
44
|
+
entry = {
|
|
45
|
+
'rule_name' => rule_name,
|
|
46
|
+
'fact_uuids' => JSON.generate(fact_uuids),
|
|
47
|
+
'bindings' => JSON.generate(bindings),
|
|
48
|
+
'fired_at' => timestamp,
|
|
49
|
+
'session_id' => @session_id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
entry_json = JSON.generate(entry)
|
|
53
|
+
|
|
54
|
+
# Add to global rules fired list
|
|
55
|
+
@redis.lpush('rules_fired:all', entry_json)
|
|
56
|
+
|
|
57
|
+
# Add to rule-specific history
|
|
58
|
+
@redis.lpush("rules_fired:#{rule_name}", entry_json)
|
|
59
|
+
|
|
60
|
+
# Limit size
|
|
61
|
+
@redis.ltrim('rules_fired:all', 0, 9999)
|
|
62
|
+
@redis.ltrim("rules_fired:#{rule_name}", 0, 999)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fact_history(fact_uuid = nil, limit: 100)
|
|
66
|
+
key = fact_uuid ? "fact_history:#{fact_uuid}" : 'fact_history:all'
|
|
67
|
+
entries_json = @redis.lrange(key, 0, limit - 1)
|
|
68
|
+
|
|
69
|
+
entries_json.map do |entry_json|
|
|
70
|
+
entry = JSON.parse(entry_json, symbolize_names: true)
|
|
71
|
+
{
|
|
72
|
+
fact_uuid: entry[:fact_uuid],
|
|
73
|
+
fact_type: entry[:fact_type].to_sym,
|
|
74
|
+
attributes: JSON.parse(entry[:attributes], symbolize_names: true),
|
|
75
|
+
action: entry[:action],
|
|
76
|
+
timestamp: Time.at(entry[:timestamp]),
|
|
77
|
+
session_id: entry[:session_id]
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def rule_firings(rule_name = nil, limit: 100)
|
|
83
|
+
key = rule_name ? "rules_fired:#{rule_name}" : 'rules_fired:all'
|
|
84
|
+
entries_json = @redis.lrange(key, 0, limit - 1)
|
|
85
|
+
|
|
86
|
+
entries_json.map do |entry_json|
|
|
87
|
+
entry = JSON.parse(entry_json, symbolize_names: true)
|
|
88
|
+
{
|
|
89
|
+
rule_name: entry[:rule_name],
|
|
90
|
+
fact_uuids: JSON.parse(entry[:fact_uuids]),
|
|
91
|
+
bindings: JSON.parse(entry[:bindings], symbolize_names: true),
|
|
92
|
+
fired_at: Time.at(entry[:fired_at]),
|
|
93
|
+
session_id: entry[:session_id]
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def stats
|
|
99
|
+
rules_fired_count = @redis.llen('rules_fired:all')
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
rules_fired: rules_fired_count
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module KBS
|
|
7
|
+
module Blackboard
|
|
8
|
+
# Redis-based message queue using sorted sets for priority ordering
|
|
9
|
+
class RedisMessageQueue
|
|
10
|
+
def initialize(redis)
|
|
11
|
+
@redis = redis
|
|
12
|
+
@message_id_counter = "message_id_counter"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def post(sender, topic, content, priority: 0)
|
|
16
|
+
message_id = @redis.incr(@message_id_counter)
|
|
17
|
+
content_json = content.is_a?(String) ? content : JSON.generate(content)
|
|
18
|
+
timestamp = Time.now.to_f
|
|
19
|
+
|
|
20
|
+
message_data = {
|
|
21
|
+
'id' => message_id,
|
|
22
|
+
'sender' => sender,
|
|
23
|
+
'topic' => topic,
|
|
24
|
+
'content' => content_json,
|
|
25
|
+
'priority' => priority,
|
|
26
|
+
'posted_at' => timestamp,
|
|
27
|
+
'consumed' => '0'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Store message data
|
|
31
|
+
@redis.hset("message:#{message_id}", message_data)
|
|
32
|
+
|
|
33
|
+
# Add to topic queue with score = -priority (for DESC ordering) + timestamp (for ASC within priority)
|
|
34
|
+
# Score: higher priority = lower score (negative), then by timestamp
|
|
35
|
+
score = (-priority * 1_000_000) + timestamp
|
|
36
|
+
@redis.zadd("messages:#{topic}", score, message_id)
|
|
37
|
+
|
|
38
|
+
message_id
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def consume(topic, consumer)
|
|
42
|
+
# Get highest priority (lowest score) unconsumed message
|
|
43
|
+
messages = @redis.zrange("messages:#{topic}", 0, 0)
|
|
44
|
+
return nil if messages.empty?
|
|
45
|
+
|
|
46
|
+
message_id = messages.first
|
|
47
|
+
message_data = @redis.hgetall("message:#{message_id}")
|
|
48
|
+
|
|
49
|
+
return nil if message_data.empty? || message_data['consumed'] == '1'
|
|
50
|
+
|
|
51
|
+
# Mark as consumed
|
|
52
|
+
@redis.multi do |pipeline|
|
|
53
|
+
pipeline.hset("message:#{message_id}", 'consumed', '1')
|
|
54
|
+
pipeline.hset("message:#{message_id}", 'consumed_by', consumer)
|
|
55
|
+
pipeline.hset("message:#{message_id}", 'consumed_at', Time.now.to_f)
|
|
56
|
+
pipeline.zrem("messages:#{topic}", message_id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
id: message_data['id'].to_i,
|
|
61
|
+
sender: message_data['sender'],
|
|
62
|
+
topic: message_data['topic'],
|
|
63
|
+
content: JSON.parse(message_data['content'], symbolize_names: true),
|
|
64
|
+
priority: message_data['priority'].to_i,
|
|
65
|
+
posted_at: Time.at(message_data['posted_at'].to_f)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def peek(topic, limit: 10)
|
|
70
|
+
# Get top N messages without consuming
|
|
71
|
+
message_ids = @redis.zrange("messages:#{topic}", 0, limit - 1)
|
|
72
|
+
messages = []
|
|
73
|
+
|
|
74
|
+
message_ids.each do |message_id|
|
|
75
|
+
message_data = @redis.hgetall("message:#{message_id}")
|
|
76
|
+
next if message_data.empty? || message_data['consumed'] == '1'
|
|
77
|
+
|
|
78
|
+
messages << {
|
|
79
|
+
id: message_data['id'].to_i,
|
|
80
|
+
sender: message_data['sender'],
|
|
81
|
+
topic: message_data['topic'],
|
|
82
|
+
content: JSON.parse(message_data['content'], symbolize_names: true),
|
|
83
|
+
priority: message_data['priority'].to_i,
|
|
84
|
+
posted_at: Time.at(message_data['posted_at'].to_f)
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
messages
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def stats
|
|
92
|
+
# Count all unconsumed messages across all topics
|
|
93
|
+
topics = @redis.keys('messages:*')
|
|
94
|
+
total_unconsumed = 0
|
|
95
|
+
|
|
96
|
+
topics.each do |topic_key|
|
|
97
|
+
total_unconsumed += @redis.zcard(topic_key)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Count all messages (including consumed)
|
|
101
|
+
all_message_keys = @redis.keys('message:*')
|
|
102
|
+
total_messages = all_message_keys.size
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
total_messages: total_messages,
|
|
106
|
+
unconsumed_messages: total_unconsumed
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../kbs'
|
|
4
|
+
|
|
5
|
+
# Blackboard pattern components
|
|
6
|
+
require_relative 'blackboard/fact'
|
|
7
|
+
require_relative 'blackboard/message_queue'
|
|
8
|
+
require_relative 'blackboard/audit_log'
|
|
9
|
+
require_relative 'blackboard/redis_message_queue'
|
|
10
|
+
require_relative 'blackboard/redis_audit_log'
|
|
11
|
+
require_relative 'blackboard/persistence/store'
|
|
12
|
+
require_relative 'blackboard/persistence/sqlite_store'
|
|
13
|
+
require_relative 'blackboard/persistence/redis_store'
|
|
14
|
+
require_relative 'blackboard/persistence/hybrid_store'
|
|
15
|
+
require_relative 'blackboard/memory'
|
|
16
|
+
require_relative 'blackboard/engine'
|
|
17
|
+
|
|
18
|
+
# Backward compatibility aliases (deprecated - will be removed in v1.0)
|
|
19
|
+
module KBS
|
|
20
|
+
BlackboardMemory = Blackboard::Memory
|
|
21
|
+
BlackboardEngine = Blackboard::Engine
|
|
22
|
+
PersistedFact = Blackboard::Fact
|
|
23
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KBS
|
|
4
|
+
class Condition
|
|
5
|
+
attr_reader :type, :pattern, :variable_bindings, :negated
|
|
6
|
+
|
|
7
|
+
def initialize(type, pattern = {}, negated: false)
|
|
8
|
+
@type = type
|
|
9
|
+
@pattern = pattern
|
|
10
|
+
@negated = negated
|
|
11
|
+
@variable_bindings = extract_variables(pattern)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def extract_variables(pattern)
|
|
17
|
+
vars = {}
|
|
18
|
+
pattern.each do |key, value|
|
|
19
|
+
if value.is_a?(Symbol) && value.to_s.start_with?('?')
|
|
20
|
+
vars[value] = key
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
vars
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KBS
|
|
4
|
+
module DSL
|
|
5
|
+
module ConditionHelpers
|
|
6
|
+
def less_than(value)
|
|
7
|
+
->(v) { v < value }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def greater_than(value)
|
|
11
|
+
->(v) { v > value }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def equals(value)
|
|
15
|
+
value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def not_equal(value)
|
|
19
|
+
->(v) { v != value }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def one_of(*values)
|
|
23
|
+
->(v) { values.include?(v) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def range(min_or_range, max = nil)
|
|
27
|
+
if min_or_range.is_a?(Range)
|
|
28
|
+
->(v) { min_or_range.include?(v) }
|
|
29
|
+
else
|
|
30
|
+
->(v) { v >= min_or_range && v <= max }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def between(min, max)
|
|
35
|
+
range(min, max)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def any(*values)
|
|
39
|
+
if values.empty?
|
|
40
|
+
# Match anything
|
|
41
|
+
->(v) { true }
|
|
42
|
+
else
|
|
43
|
+
# Match one of the values
|
|
44
|
+
one_of(*values)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def matches(pattern)
|
|
49
|
+
->(v) { v.match?(pattern) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def satisfies(&block)
|
|
53
|
+
block
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KBS
|
|
4
|
+
module DSL
|
|
5
|
+
class KnowledgeBase
|
|
6
|
+
attr_reader :engine, :rules
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@engine = ReteEngine.new
|
|
10
|
+
@rules = {}
|
|
11
|
+
@rule_builders = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rule(name, &block)
|
|
15
|
+
builder = RuleBuilder.new(name)
|
|
16
|
+
builder.instance_eval(&block) if block_given?
|
|
17
|
+
@rule_builders[name] = builder
|
|
18
|
+
rule = builder.build
|
|
19
|
+
@rules[name] = rule
|
|
20
|
+
@engine.add_rule(rule)
|
|
21
|
+
builder
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def defrule(name, &block)
|
|
25
|
+
rule(name, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fact(type, attributes = {})
|
|
29
|
+
@engine.add_fact(type, attributes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def assert(type, attributes = {})
|
|
33
|
+
fact(type, attributes)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def retract(fact)
|
|
37
|
+
@engine.remove_fact(fact)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
@engine.run
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset
|
|
45
|
+
@engine.working_memory.facts.clear
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def facts
|
|
49
|
+
@engine.working_memory.facts
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def query(type, pattern = {})
|
|
53
|
+
@engine.working_memory.facts.select do |fact|
|
|
54
|
+
next false unless fact.type == type
|
|
55
|
+
pattern.all? { |key, value| fact.attributes[key] == value }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def print_facts
|
|
60
|
+
puts "Working Memory Contents:"
|
|
61
|
+
puts "-" * 40
|
|
62
|
+
facts.each_with_index do |fact, i|
|
|
63
|
+
puts "#{i + 1}. #{fact}"
|
|
64
|
+
end
|
|
65
|
+
puts "-" * 40
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def print_rules
|
|
69
|
+
puts "Knowledge Base Rules:"
|
|
70
|
+
puts "-" * 40
|
|
71
|
+
@rule_builders.each do |name, builder|
|
|
72
|
+
puts "Rule: #{name}"
|
|
73
|
+
puts " Description: #{builder.description}" if builder.description
|
|
74
|
+
puts " Priority: #{builder.priority}"
|
|
75
|
+
puts " Conditions: #{builder.conditions.size}"
|
|
76
|
+
builder.conditions.each_with_index do |cond, i|
|
|
77
|
+
negated = cond.negated ? "NOT " : ""
|
|
78
|
+
puts " #{i + 1}. #{negated}#{cond.type}(#{cond.pattern})"
|
|
79
|
+
end
|
|
80
|
+
puts ""
|
|
81
|
+
end
|
|
82
|
+
puts "-" * 40
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|