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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'persistence/store'
|
|
5
|
+
require_relative 'persistence/sqlite_store'
|
|
6
|
+
require_relative 'message_queue'
|
|
7
|
+
require_relative 'audit_log'
|
|
8
|
+
require_relative 'fact'
|
|
9
|
+
|
|
10
|
+
module KBS
|
|
11
|
+
module Blackboard
|
|
12
|
+
# The Blackboard Memory - central workspace for facts and coordination
|
|
13
|
+
class Memory
|
|
14
|
+
attr_reader :session_id, :store, :message_queue, :audit_log
|
|
15
|
+
|
|
16
|
+
def initialize(db_path: ':memory:', store: nil)
|
|
17
|
+
@session_id = SecureRandom.uuid
|
|
18
|
+
@observers = []
|
|
19
|
+
|
|
20
|
+
# Use provided store or create default SqliteStore
|
|
21
|
+
@store = store || Persistence::SqliteStore.new(
|
|
22
|
+
db_path: db_path,
|
|
23
|
+
session_id: @session_id
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Initialize composed components based on store type
|
|
27
|
+
setup_components
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def setup_components
|
|
33
|
+
# Detect store type and create appropriate MessageQueue and AuditLog
|
|
34
|
+
if @store.respond_to?(:hybrid?) && @store.hybrid?
|
|
35
|
+
# Hybrid store: Redis for messages, SQLite for audit
|
|
36
|
+
require_relative 'redis_message_queue'
|
|
37
|
+
@message_queue = RedisMessageQueue.new(@store.connection)
|
|
38
|
+
@audit_log = AuditLog.new(@store.db, @session_id)
|
|
39
|
+
elsif @store.respond_to?(:connection)
|
|
40
|
+
# Pure Redis store
|
|
41
|
+
require_relative 'redis_message_queue'
|
|
42
|
+
require_relative 'redis_audit_log'
|
|
43
|
+
@message_queue = RedisMessageQueue.new(@store.connection)
|
|
44
|
+
@audit_log = RedisAuditLog.new(@store.connection, @session_id)
|
|
45
|
+
elsif @store.respond_to?(:db)
|
|
46
|
+
# Pure SQLite store
|
|
47
|
+
@message_queue = MessageQueue.new(@store.db)
|
|
48
|
+
@audit_log = AuditLog.new(@store.db, @session_id)
|
|
49
|
+
else
|
|
50
|
+
raise ArgumentError, "Store must respond to either :connection (Redis) or :db (SQLite)"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
public
|
|
55
|
+
|
|
56
|
+
# Fact Management
|
|
57
|
+
def add_fact(type, attributes = {})
|
|
58
|
+
uuid = SecureRandom.uuid
|
|
59
|
+
|
|
60
|
+
@store.transaction do
|
|
61
|
+
@store.add_fact(uuid, type, attributes)
|
|
62
|
+
@audit_log.log_fact_change(uuid, type, attributes, 'ADD')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
fact = Fact.new(uuid, type, attributes, self)
|
|
66
|
+
notify_observers(:add, fact)
|
|
67
|
+
fact
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def remove_fact(fact)
|
|
71
|
+
uuid = fact.is_a?(Fact) ? fact.uuid : fact
|
|
72
|
+
|
|
73
|
+
@store.transaction do
|
|
74
|
+
result = @store.remove_fact(uuid)
|
|
75
|
+
|
|
76
|
+
if result
|
|
77
|
+
@audit_log.log_fact_change(uuid, result[:type], result[:attributes], 'REMOVE')
|
|
78
|
+
|
|
79
|
+
fact_obj = Fact.new(uuid, result[:type], result[:attributes], self)
|
|
80
|
+
notify_observers(:remove, fact_obj)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def update_fact(fact, new_attributes)
|
|
86
|
+
uuid = fact.is_a?(Fact) ? fact.uuid : fact
|
|
87
|
+
|
|
88
|
+
@store.transaction do
|
|
89
|
+
fact_type = @store.update_fact(uuid, new_attributes)
|
|
90
|
+
|
|
91
|
+
if fact_type
|
|
92
|
+
@audit_log.log_fact_change(uuid, fact_type, new_attributes, 'UPDATE')
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def get_facts(type = nil, pattern = {})
|
|
98
|
+
fact_data = @store.get_facts(type, pattern)
|
|
99
|
+
|
|
100
|
+
fact_data.map do |data|
|
|
101
|
+
Fact.new(data[:uuid], data[:type], data[:attributes], self)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Alias for compatibility with WorkingMemory interface
|
|
106
|
+
def facts
|
|
107
|
+
get_facts
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def query_facts(sql_conditions = nil, params = [])
|
|
111
|
+
fact_data = @store.query_facts(sql_conditions, params)
|
|
112
|
+
|
|
113
|
+
fact_data.map do |data|
|
|
114
|
+
Fact.new(data[:uuid], data[:type], data[:attributes], self)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Message Queue delegation
|
|
119
|
+
def post_message(sender, topic, content, priority: 0)
|
|
120
|
+
@message_queue.post(sender, topic, content, priority: priority)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def consume_message(topic, consumer)
|
|
124
|
+
@store.transaction do
|
|
125
|
+
@message_queue.consume(topic, consumer)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def peek_messages(topic, limit: 10)
|
|
130
|
+
@message_queue.peek(topic, limit: limit)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Knowledge Source registry
|
|
134
|
+
def register_knowledge_source(name, description: nil, topics: [])
|
|
135
|
+
@store.register_knowledge_source(name, description: description, topics: topics)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Audit Log delegation
|
|
139
|
+
def log_rule_firing(rule_name, fact_uuids, bindings = {})
|
|
140
|
+
@audit_log.log_rule_firing(rule_name, fact_uuids, bindings)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def get_history(fact_uuid = nil, limit: 100)
|
|
144
|
+
@audit_log.fact_history(fact_uuid, limit: limit)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def get_rule_firings(rule_name = nil, limit: 100)
|
|
148
|
+
@audit_log.rule_firings(rule_name, limit: limit)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Observer pattern
|
|
152
|
+
def add_observer(observer)
|
|
153
|
+
@observers << observer
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def notify_observers(action, fact)
|
|
157
|
+
@observers.each { |obs| obs.update(action, fact) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Session management
|
|
161
|
+
def clear_session
|
|
162
|
+
@store.clear_session(@session_id)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def transaction(&block)
|
|
166
|
+
@store.transaction(&block)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Statistics
|
|
170
|
+
def stats
|
|
171
|
+
store_stats = @store.stats
|
|
172
|
+
message_stats = @message_queue.stats
|
|
173
|
+
audit_stats = @audit_log.stats
|
|
174
|
+
|
|
175
|
+
store_stats.merge(message_stats).merge(audit_stats)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Maintenance
|
|
179
|
+
def vacuum
|
|
180
|
+
@store.vacuum
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def close
|
|
184
|
+
@store.close
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# For backward compatibility with ReteEngine
|
|
188
|
+
alias_method :db, :store
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module KBS
|
|
7
|
+
module Blackboard
|
|
8
|
+
class MessageQueue
|
|
9
|
+
def initialize(db)
|
|
10
|
+
@db = db
|
|
11
|
+
setup_table
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def setup_table
|
|
15
|
+
@db.execute_batch <<-SQL
|
|
16
|
+
CREATE TABLE IF NOT EXISTS blackboard_messages (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
sender TEXT NOT NULL,
|
|
19
|
+
topic TEXT NOT NULL,
|
|
20
|
+
content TEXT NOT NULL,
|
|
21
|
+
priority INTEGER DEFAULT 0,
|
|
22
|
+
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
23
|
+
consumed BOOLEAN DEFAULT 0,
|
|
24
|
+
consumed_by TEXT,
|
|
25
|
+
consumed_at TIMESTAMP
|
|
26
|
+
);
|
|
27
|
+
SQL
|
|
28
|
+
|
|
29
|
+
@db.execute <<-SQL
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_messages_topic ON blackboard_messages(topic);
|
|
31
|
+
SQL
|
|
32
|
+
|
|
33
|
+
@db.execute <<-SQL
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_messages_consumed ON blackboard_messages(consumed);
|
|
35
|
+
SQL
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def post(sender, topic, content, priority: 0)
|
|
39
|
+
content_json = content.is_a?(String) ? content : JSON.generate(content)
|
|
40
|
+
|
|
41
|
+
@db.execute(
|
|
42
|
+
"INSERT INTO blackboard_messages (sender, topic, content, priority) VALUES (?, ?, ?, ?)",
|
|
43
|
+
[sender, topic, content_json, priority]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def consume(topic, consumer)
|
|
48
|
+
result = @db.get_first_row(
|
|
49
|
+
"SELECT * FROM blackboard_messages WHERE topic = ? AND consumed = 0 ORDER BY priority DESC, posted_at ASC LIMIT 1",
|
|
50
|
+
[topic]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if result
|
|
54
|
+
@db.execute(
|
|
55
|
+
"UPDATE blackboard_messages SET consumed = 1, consumed_by = ?, consumed_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
56
|
+
[consumer, result['id']]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
id: result['id'],
|
|
61
|
+
sender: result['sender'],
|
|
62
|
+
topic: result['topic'],
|
|
63
|
+
content: JSON.parse(result['content'], symbolize_names: true),
|
|
64
|
+
priority: result['priority'],
|
|
65
|
+
posted_at: Time.parse(result['posted_at'])
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def peek(topic, limit: 10)
|
|
71
|
+
results = @db.execute(
|
|
72
|
+
"SELECT * FROM blackboard_messages WHERE topic = ? AND consumed = 0 ORDER BY priority DESC, posted_at ASC LIMIT ?",
|
|
73
|
+
[topic, limit]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
results.map do |row|
|
|
77
|
+
{
|
|
78
|
+
id: row['id'],
|
|
79
|
+
sender: row['sender'],
|
|
80
|
+
topic: row['topic'],
|
|
81
|
+
content: JSON.parse(row['content'], symbolize_names: true),
|
|
82
|
+
priority: row['priority'],
|
|
83
|
+
posted_at: Time.parse(row['posted_at'])
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stats
|
|
89
|
+
{
|
|
90
|
+
total_messages: @db.get_first_value("SELECT COUNT(*) FROM blackboard_messages"),
|
|
91
|
+
unconsumed_messages: @db.get_first_value("SELECT COUNT(*) FROM blackboard_messages WHERE consumed = 0")
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'store'
|
|
4
|
+
require_relative 'redis_store'
|
|
5
|
+
require_relative 'sqlite_store'
|
|
6
|
+
|
|
7
|
+
module KBS
|
|
8
|
+
module Blackboard
|
|
9
|
+
module Persistence
|
|
10
|
+
# Hybrid store combining Redis (facts, messages) with SQLite (audit trail)
|
|
11
|
+
#
|
|
12
|
+
# Benefits:
|
|
13
|
+
# - Fast in-memory fact access via Redis
|
|
14
|
+
# - Durable audit trail via SQLite
|
|
15
|
+
# - Best of both worlds for production systems
|
|
16
|
+
class HybridStore < Store
|
|
17
|
+
attr_reader :redis_store, :sqlite_store, :session_id
|
|
18
|
+
|
|
19
|
+
def initialize(
|
|
20
|
+
redis_url: 'redis://localhost:6379/0',
|
|
21
|
+
redis: nil,
|
|
22
|
+
db_path: 'audit.db',
|
|
23
|
+
session_id: nil
|
|
24
|
+
)
|
|
25
|
+
@session_id = session_id
|
|
26
|
+
|
|
27
|
+
# Redis for hot data (facts, messages)
|
|
28
|
+
@redis_store = RedisStore.new(
|
|
29
|
+
url: redis_url,
|
|
30
|
+
redis: redis,
|
|
31
|
+
session_id: @session_id
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# SQLite for cold data (audit trail)
|
|
35
|
+
@sqlite_store = SqliteStore.new(
|
|
36
|
+
db_path: db_path,
|
|
37
|
+
session_id: @session_id
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Fact operations delegated to Redis (fast)
|
|
42
|
+
def add_fact(uuid, type, attributes)
|
|
43
|
+
@redis_store.add_fact(uuid, type, attributes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remove_fact(uuid)
|
|
47
|
+
@redis_store.remove_fact(uuid)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def update_fact(uuid, attributes)
|
|
51
|
+
@redis_store.update_fact(uuid, attributes)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_fact(uuid)
|
|
55
|
+
@redis_store.get_fact(uuid)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_facts(type = nil, pattern = {})
|
|
59
|
+
@redis_store.get_facts(type, pattern)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def query_facts(conditions = nil, params = [])
|
|
63
|
+
@redis_store.query_facts(conditions, params)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def register_knowledge_source(name, description: nil, topics: [])
|
|
67
|
+
@redis_store.register_knowledge_source(name, description: description, topics: topics)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def clear_session(session_id)
|
|
71
|
+
@redis_store.clear_session(session_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Stats combined from both stores
|
|
75
|
+
def stats
|
|
76
|
+
redis_stats = @redis_store.stats
|
|
77
|
+
sqlite_stats = @sqlite_store.stats
|
|
78
|
+
|
|
79
|
+
# Prefer Redis for fact counts (authoritative)
|
|
80
|
+
redis_stats.merge(
|
|
81
|
+
audit_records: sqlite_stats[:total_facts] # SQLite tracks audit records
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def vacuum
|
|
86
|
+
@redis_store.vacuum
|
|
87
|
+
@sqlite_store.vacuum
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transaction(&block)
|
|
91
|
+
# Redis and SQLite transactions are separate
|
|
92
|
+
# Execute block in context of both
|
|
93
|
+
yield
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def close
|
|
97
|
+
@redis_store.close
|
|
98
|
+
@sqlite_store.close
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Provide access to both connections for MessageQueue/AuditLog
|
|
102
|
+
# Memory class will detect hybrid store and use appropriate components
|
|
103
|
+
def connection
|
|
104
|
+
@redis_store.connection
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def db
|
|
108
|
+
@sqlite_store.db
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Helper to check if this is a hybrid store
|
|
112
|
+
def hybrid?
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'redis'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative 'store'
|
|
7
|
+
|
|
8
|
+
module KBS
|
|
9
|
+
module Blackboard
|
|
10
|
+
module Persistence
|
|
11
|
+
class RedisStore < Store
|
|
12
|
+
attr_reader :redis, :session_id
|
|
13
|
+
|
|
14
|
+
def initialize(url: 'redis://localhost:6379/0', session_id: nil, redis: nil)
|
|
15
|
+
@session_id = session_id || SecureRandom.uuid
|
|
16
|
+
@redis = redis || Redis.new(url: url)
|
|
17
|
+
@transaction_depth = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add_fact(uuid, type, attributes)
|
|
21
|
+
attributes_json = JSON.generate(attributes)
|
|
22
|
+
timestamp = Time.now.to_f
|
|
23
|
+
|
|
24
|
+
@redis.multi do |pipeline|
|
|
25
|
+
pipeline.hset("fact:#{uuid}", {
|
|
26
|
+
'uuid' => uuid,
|
|
27
|
+
'type' => type.to_s,
|
|
28
|
+
'attributes' => attributes_json,
|
|
29
|
+
'session_id' => @session_id,
|
|
30
|
+
'created_at' => timestamp,
|
|
31
|
+
'updated_at' => timestamp,
|
|
32
|
+
'retracted' => '0'
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
# Indexes
|
|
36
|
+
pipeline.sadd('facts:active', uuid)
|
|
37
|
+
pipeline.sadd("facts:type:#{type}", uuid)
|
|
38
|
+
pipeline.sadd("facts:session:#{@session_id}", uuid) if @session_id
|
|
39
|
+
pipeline.sadd('facts:all', uuid)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def remove_fact(uuid)
|
|
44
|
+
fact_data = @redis.hgetall("fact:#{uuid}")
|
|
45
|
+
return nil if fact_data.empty? || fact_data['retracted'] == '1'
|
|
46
|
+
|
|
47
|
+
type = fact_data['type'].to_sym
|
|
48
|
+
attributes = JSON.parse(fact_data['attributes'], symbolize_names: true)
|
|
49
|
+
|
|
50
|
+
@redis.multi do |pipeline|
|
|
51
|
+
pipeline.hset("fact:#{uuid}", 'retracted', '1')
|
|
52
|
+
pipeline.hset("fact:#{uuid}", 'retracted_at', Time.now.to_f)
|
|
53
|
+
pipeline.srem('facts:active', uuid)
|
|
54
|
+
pipeline.srem("facts:type:#{type}", uuid)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
{ type: type, attributes: attributes }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_fact(uuid, attributes)
|
|
61
|
+
fact_data = @redis.hgetall("fact:#{uuid}")
|
|
62
|
+
return nil if fact_data.empty? || fact_data['retracted'] == '1'
|
|
63
|
+
|
|
64
|
+
attributes_json = JSON.generate(attributes)
|
|
65
|
+
|
|
66
|
+
@redis.multi do |pipeline|
|
|
67
|
+
pipeline.hset("fact:#{uuid}", 'attributes', attributes_json)
|
|
68
|
+
pipeline.hset("fact:#{uuid}", 'updated_at', Time.now.to_f)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
fact_data['type'].to_sym
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def get_fact(uuid)
|
|
75
|
+
fact_data = @redis.hgetall("fact:#{uuid}")
|
|
76
|
+
return nil if fact_data.empty? || fact_data['retracted'] == '1'
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
uuid: fact_data['uuid'],
|
|
80
|
+
type: fact_data['type'].to_sym,
|
|
81
|
+
attributes: JSON.parse(fact_data['attributes'], symbolize_names: true)
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def get_facts(type = nil, pattern = {})
|
|
86
|
+
# Get UUIDs from appropriate index
|
|
87
|
+
uuids = if type
|
|
88
|
+
@redis.sinter('facts:active', "facts:type:#{type}")
|
|
89
|
+
else
|
|
90
|
+
@redis.smembers('facts:active')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Fetch and filter facts
|
|
94
|
+
facts = []
|
|
95
|
+
uuids.each do |uuid|
|
|
96
|
+
fact_data = @redis.hgetall("fact:#{uuid}")
|
|
97
|
+
next if fact_data.empty? || fact_data['retracted'] == '1'
|
|
98
|
+
|
|
99
|
+
attributes = JSON.parse(fact_data['attributes'], symbolize_names: true)
|
|
100
|
+
|
|
101
|
+
if matches_pattern?(attributes, pattern)
|
|
102
|
+
facts << {
|
|
103
|
+
uuid: fact_data['uuid'],
|
|
104
|
+
type: fact_data['type'].to_sym,
|
|
105
|
+
attributes: attributes
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
facts
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def query_facts(conditions = nil, params = [])
|
|
114
|
+
# Redis doesn't support SQL queries
|
|
115
|
+
# For complex queries, use get_facts with pattern matching
|
|
116
|
+
# or implement custom Redis Lua scripts
|
|
117
|
+
get_facts
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def register_knowledge_source(name, description: nil, topics: [])
|
|
121
|
+
topics_json = JSON.generate(topics)
|
|
122
|
+
|
|
123
|
+
@redis.hset("ks:#{name}", {
|
|
124
|
+
'name' => name,
|
|
125
|
+
'description' => description,
|
|
126
|
+
'topics' => topics_json,
|
|
127
|
+
'active' => '1',
|
|
128
|
+
'registered_at' => Time.now.to_f
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
@redis.sadd('knowledge_sources:active', name)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def clear_session(session_id)
|
|
135
|
+
uuids = @redis.smembers("facts:session:#{session_id}")
|
|
136
|
+
|
|
137
|
+
uuids.each do |uuid|
|
|
138
|
+
remove_fact(uuid)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@redis.del("facts:session:#{session_id}")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def vacuum
|
|
145
|
+
# Remove retracted facts from Redis to free memory
|
|
146
|
+
all_uuids = @redis.smembers('facts:all')
|
|
147
|
+
|
|
148
|
+
all_uuids.each do |uuid|
|
|
149
|
+
fact_data = @redis.hgetall("fact:#{uuid}")
|
|
150
|
+
if fact_data['retracted'] == '1'
|
|
151
|
+
# Calculate if fact is old enough to remove (e.g., > 30 days)
|
|
152
|
+
retracted_at = fact_data['retracted_at'].to_f
|
|
153
|
+
if Time.now.to_f - retracted_at > (30 * 24 * 60 * 60)
|
|
154
|
+
type = fact_data['type']
|
|
155
|
+
session_id = fact_data['session_id']
|
|
156
|
+
|
|
157
|
+
@redis.multi do |pipeline|
|
|
158
|
+
pipeline.del("fact:#{uuid}")
|
|
159
|
+
pipeline.srem('facts:all', uuid)
|
|
160
|
+
pipeline.srem("facts:type:#{type}", uuid)
|
|
161
|
+
pipeline.srem("facts:session:#{session_id}", uuid) if session_id
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def stats
|
|
169
|
+
active_count = @redis.scard('facts:active')
|
|
170
|
+
total_count = @redis.scard('facts:all')
|
|
171
|
+
ks_count = @redis.scard('knowledge_sources:active')
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
total_facts: total_count,
|
|
175
|
+
active_facts: active_count,
|
|
176
|
+
knowledge_sources: ks_count
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def transaction(&block)
|
|
181
|
+
@transaction_depth += 1
|
|
182
|
+
begin
|
|
183
|
+
if @transaction_depth == 1
|
|
184
|
+
# Redis MULTI/EXEC happens in individual operations
|
|
185
|
+
# This provides a consistent interface
|
|
186
|
+
yield
|
|
187
|
+
else
|
|
188
|
+
yield
|
|
189
|
+
end
|
|
190
|
+
ensure
|
|
191
|
+
@transaction_depth -= 1
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def close
|
|
196
|
+
@redis.close if @redis
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Redis-specific helper to get connection for MessageQueue/AuditLog
|
|
200
|
+
def connection
|
|
201
|
+
@redis
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def matches_pattern?(attributes, pattern)
|
|
207
|
+
pattern.all? do |key, value|
|
|
208
|
+
if value.is_a?(Proc)
|
|
209
|
+
attributes[key] && value.call(attributes[key])
|
|
210
|
+
else
|
|
211
|
+
attributes[key] == value
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|