smart_brain 0.1.2 → 0.2.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 +4 -4
- data/CHANGELOG.md +15 -0
- data/MEMPAL_GUIDE.md +1074 -0
- data/README.en.md +173 -173
- data/README.md +467 -173
- data/config/brain.yml +69 -1
- data/conversation_demo.rb +438 -438
- data/db/migrate/002_turn_events_payload.sql +9 -0
- data/db/migrate/003_tiers_and_lifecycle.sql +28 -0
- data/db/migrate/004_kg_edges.sql +30 -0
- data/db/migrate/005_domains_and_memory_scopes.sql +163 -0
- data/docs/coding_todo.md +139 -0
- data/docs/context_package.md +220 -0
- data/docs/evidence_pack.md +190 -0
- data/docs/gap_vs_mempal.md +161 -0
- data/docs/mcp.md +93 -0
- data/docs/memory_types.md +278 -0
- data/docs/multi_scope_memory_refactor_plan.md +483 -0
- data/docs/multi_scope_migration.md +65 -0
- data/docs/policies.md +308 -0
- data/docs/retrieval_plan.md +231 -0
- data/docs/smartbrain_design.md +299 -0
- data/docs/user_guide.md +546 -0
- data/example.rb +91 -91
- data/examples/01_memory_basic.rb +57 -0
- data/examples/02_governance.rb +63 -0
- data/examples/03_postgres_persistence.rb +63 -0
- data/examples/04_ollama_llm.rb +69 -0
- data/examples/05_smart_rag_integration.rb +79 -0
- data/examples/06_multi_scope_memory.rb +50 -0
- data/examples/README.md +49 -0
- data/exe/smart_brain +168 -0
- data/lib/smart_brain/adapters/smart_rag/direct_client.rb +16 -5
- data/lib/smart_brain/adapters/smart_rag/http_client.rb +16 -5
- data/lib/smart_brain/adapters/smart_rag/null_client.rb +7 -2
- data/lib/smart_brain/adapters/smart_rag/scope_filter.rb +60 -0
- data/lib/smart_brain/configuration.rb +57 -0
- data/lib/smart_brain/consolidator/working_summary.rb +80 -12
- data/lib/smart_brain/context_composer/composer.rb +40 -3
- data/lib/smart_brain/contracts/retrieval_plan.rb +10 -0
- data/lib/smart_brain/contracts/scope_context.rb +46 -0
- data/lib/smart_brain/contracts/scope_ref.rb +25 -0
- data/lib/smart_brain/db.rb +109 -0
- data/lib/smart_brain/event_store/in_memory.rb +6 -2
- data/lib/smart_brain/event_store/postgres.rb +199 -0
- data/lib/smart_brain/fusion/merger.rb +31 -2
- data/lib/smart_brain/governance/briefing.rb +146 -0
- data/lib/smart_brain/governance/fact_check.rb +110 -0
- data/lib/smart_brain/governance/knowledge_graph.rb +60 -0
- data/lib/smart_brain/governance/lifecycle.rb +225 -0
- data/lib/smart_brain/governance/tiers.rb +60 -0
- data/lib/smart_brain/memory_extractor/extractor.rb +25 -7
- data/lib/smart_brain/memory_store/in_memory.rb +202 -17
- data/lib/smart_brain/memory_store/postgres.rb +500 -0
- data/lib/smart_brain/model_provider/base.rb +87 -0
- data/lib/smart_brain/model_provider/factory.rb +49 -0
- data/lib/smart_brain/model_provider/ollama.rb +60 -0
- data/lib/smart_brain/model_provider/openai.rb +60 -0
- data/lib/smart_brain/model_provider/stub.rb +26 -0
- data/lib/smart_brain/model_provider.rb +7 -0
- data/lib/smart_brain/observability/tracker.rb +39 -1
- data/lib/smart_brain/retrievers/exact_retriever.rb +6 -0
- data/lib/smart_brain/retrievers/memory_retriever.rb +59 -5
- data/lib/smart_brain/runtime.rb +288 -16
- data/lib/smart_brain/scopes/conflict_resolver.rb +67 -0
- data/lib/smart_brain/scopes/registry.rb +133 -0
- data/lib/smart_brain/scopes/resolver.rb +32 -0
- data/lib/smart_brain/server/http_app.rb +143 -0
- data/lib/smart_brain/server/mcp_server.rb +385 -0
- data/lib/smart_brain/server/service.rb +129 -0
- data/lib/smart_brain/support/levenshtein.rb +35 -0
- data/lib/smart_brain/version.rb +5 -5
- data/lib/smart_brain.rb +80 -35
- metadata +88 -36
data/lib/smart_brain/runtime.rb
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require_relative 'model_provider/stub'
|
|
5
|
+
require_relative 'governance/lifecycle'
|
|
6
|
+
require_relative 'governance/knowledge_graph'
|
|
7
|
+
require_relative 'governance/fact_check'
|
|
8
|
+
require_relative 'governance/briefing'
|
|
4
9
|
require_relative 'contracts/retrieval_plan'
|
|
5
10
|
require_relative 'contracts/evidence_pack'
|
|
6
11
|
require_relative 'contracts/context_package'
|
|
@@ -14,33 +19,60 @@ require_relative 'retrievers/memory_retriever'
|
|
|
14
19
|
require_relative 'adapters/smart_rag/null_client'
|
|
15
20
|
require_relative 'fusion/merger'
|
|
16
21
|
require_relative 'context_composer/composer'
|
|
22
|
+
require_relative 'scopes/registry'
|
|
23
|
+
require_relative 'scopes/resolver'
|
|
24
|
+
require_relative 'scopes/conflict_resolver'
|
|
17
25
|
|
|
18
26
|
module SmartBrain
|
|
19
27
|
class Runtime
|
|
20
28
|
def self.build(config:, smart_rag_client: nil, clock:)
|
|
21
|
-
|
|
22
|
-
memory_store =
|
|
29
|
+
require_relative 'model_provider'
|
|
30
|
+
event_store, memory_store, scope_registry = build_stores(config: config, clock: clock)
|
|
31
|
+
model_provider = ModelProvider.build(config)
|
|
23
32
|
new(
|
|
24
33
|
config: config,
|
|
25
34
|
clock: clock,
|
|
26
35
|
event_store: event_store,
|
|
27
36
|
memory_store: memory_store,
|
|
37
|
+
scope_registry: scope_registry,
|
|
38
|
+
scope_resolver: Scopes::Resolver.new,
|
|
39
|
+
conflict_resolver: Scopes::ConflictResolver.new(config: config),
|
|
28
40
|
extractor: MemoryExtractor::Extractor.new(config: config),
|
|
29
|
-
consolidator: Consolidator::WorkingSummary.new(config: config, clock: clock),
|
|
41
|
+
consolidator: Consolidator::WorkingSummary.new(config: config, clock: clock, memory_store: memory_store, model_provider: model_provider),
|
|
30
42
|
planner: RetrievalPlanner::Planner.new(config: config),
|
|
31
43
|
memory_retriever: Retrievers::MemoryRetriever.new(config: config),
|
|
32
44
|
smart_rag_client: smart_rag_client || Adapters::SmartRag::NullClient.new,
|
|
33
|
-
merger: Fusion::Merger.new(config: config),
|
|
45
|
+
merger: Fusion::Merger.new(config: config, model_provider: model_provider),
|
|
34
46
|
composer: ContextComposer::Composer.new(config: config, clock: clock),
|
|
35
|
-
tracker: Observability::Tracker.new
|
|
47
|
+
tracker: Observability::Tracker.new,
|
|
48
|
+
model_provider: model_provider
|
|
36
49
|
)
|
|
37
50
|
end
|
|
38
51
|
|
|
39
|
-
def
|
|
52
|
+
def self.build_stores(config:, clock:)
|
|
53
|
+
if config.storage_backend == 'postgres'
|
|
54
|
+
require_relative 'db'
|
|
55
|
+
require_relative 'event_store/postgres'
|
|
56
|
+
require_relative 'memory_store/postgres'
|
|
57
|
+
db = SmartBrain::DB.connect(config)
|
|
58
|
+
SmartBrain::DB.migrate(db) # idempotent; self-provisions the schema
|
|
59
|
+
[EventStore::Postgres.new(db: db, clock: clock),
|
|
60
|
+
MemoryStore::Postgres.new(db: db, config: config),
|
|
61
|
+
Scopes::Registry.new(db: db, clock: clock)]
|
|
62
|
+
else
|
|
63
|
+
[EventStore::InMemory.new, MemoryStore::InMemory.new, Scopes::Registry.new(clock: clock)]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(config:, clock:, event_store:, memory_store:, scope_registry:, scope_resolver:, conflict_resolver:, extractor:, consolidator:, planner:, memory_retriever:, smart_rag_client:, merger:, composer:, tracker:, model_provider: SmartBrain::ModelProvider::Stub.new)
|
|
40
68
|
@config = config
|
|
41
69
|
@clock = clock
|
|
70
|
+
@model_provider = model_provider
|
|
42
71
|
@event_store = event_store
|
|
43
72
|
@memory_store = memory_store
|
|
73
|
+
@scope_registry = scope_registry
|
|
74
|
+
@scope_resolver = scope_resolver
|
|
75
|
+
@conflict_resolver = conflict_resolver
|
|
44
76
|
@extractor = extractor
|
|
45
77
|
@consolidator = consolidator
|
|
46
78
|
@planner = planner
|
|
@@ -49,19 +81,142 @@ module SmartBrain
|
|
|
49
81
|
@merger = merger
|
|
50
82
|
@composer = composer
|
|
51
83
|
@tracker = tracker
|
|
84
|
+
@lifecycle = Governance::Lifecycle.new(memory_store: memory_store, config: config, model_provider: model_provider)
|
|
85
|
+
@knowledge_graph = Governance::KnowledgeGraph.new(memory_store: memory_store, config: config)
|
|
86
|
+
@fact_check = Governance::FactCheck.new(memory_store: memory_store, config: config)
|
|
87
|
+
@briefing = Governance::Briefing.new(memory_store: memory_store, event_store: event_store, config: config)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def distill(session_id:, domain_id: nil, scope_context: nil, **options)
|
|
91
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
92
|
+
target = resolved[:default_write_scope]
|
|
93
|
+
lifecycle.distill(
|
|
94
|
+
session_id: session_id, scope_id: target[:scope_id], scope: target.slice(:type, :id), **options
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def gate(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil)
|
|
99
|
+
authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
|
|
100
|
+
lifecycle.gate(memory_item_id: memory_item_id)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def promote(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil, **options)
|
|
104
|
+
authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
|
|
105
|
+
lifecycle.promote(memory_item_id: memory_item_id, **options)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def demote(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil, **options)
|
|
109
|
+
authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
|
|
110
|
+
lifecycle.demote(memory_item_id: memory_item_id, **options)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def retract(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil, **options)
|
|
114
|
+
authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
|
|
115
|
+
lifecycle.retract(memory_item_id: memory_item_id, **options)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def promote_to_scope(memory_item_id:, target_scope:, domain_id:, session_id:, scope_context:,
|
|
119
|
+
verification_refs:, reason:, reviewer: nil)
|
|
120
|
+
source = authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
|
|
121
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
122
|
+
normalized_target = normalize_scope_ref(target_scope)
|
|
123
|
+
target = resolved[:scopes].find do |scope|
|
|
124
|
+
scope[:type] == normalized_target[:type] && scope[:id] == normalized_target[:id]
|
|
125
|
+
end
|
|
126
|
+
writable = resolved[:write].any? { |scope| scope == normalized_target }
|
|
127
|
+
scope_validation_failure!("target scope is not writable: #{normalized_target[:type]}:#{normalized_target[:id]}") unless target && writable
|
|
128
|
+
|
|
129
|
+
result = lifecycle.promote_to_scope(
|
|
130
|
+
source_item: source, target_scope: target, session_id: session_id,
|
|
131
|
+
verification_refs: verification_refs, reason: reason, reviewer: reviewer
|
|
132
|
+
)
|
|
133
|
+
tracker.log_scope_promotion(result)
|
|
134
|
+
result
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def demote_from_scope(...) = demote(...)
|
|
138
|
+
|
|
139
|
+
def promotion_lineage(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil)
|
|
140
|
+
authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
|
|
141
|
+
lifecycle.lineage(memory_item_id: memory_item_id)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def knowledge_events(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil)
|
|
145
|
+
authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
|
|
146
|
+
lifecycle.events(memory_item_id: memory_item_id)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def kg_add(session_id:, domain_id: nil, scope_context: nil, scope_ref: nil, **options)
|
|
150
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
151
|
+
target_ref = normalize_scope_ref(scope_ref || resolved[:default_write])
|
|
152
|
+
target = resolved[:scopes].find { |scope| scope[:type] == target_ref[:type] && scope[:id] == target_ref[:id] }
|
|
153
|
+
writable = resolved[:write].include?(target_ref)
|
|
154
|
+
scope_validation_failure!("target scope is not writable: #{target_ref[:type]}:#{target_ref[:id]}") unless target && writable
|
|
155
|
+
|
|
156
|
+
knowledge_graph.add(
|
|
157
|
+
session_id: session_id, scope_id: target[:scope_id], scope: target_ref,
|
|
158
|
+
source_session_id: session_id, **options
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def kg_query(session_id:, domain_id: nil, scope_context: nil, **options)
|
|
163
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
164
|
+
knowledge_graph.query(scope_ids: resolved[:scope_ids], **options)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def kg_timeline(session_id:, domain_id: nil, scope_context: nil, **options)
|
|
168
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
169
|
+
knowledge_graph.timeline(scope_ids: resolved[:scope_ids], **options)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def kg_invalidate(edge_id:, reason: nil, domain_id: nil, session_id: nil, scope_context: nil)
|
|
173
|
+
authorize_edge!(edge_id, domain_id:, session_id:, scope_context:)
|
|
174
|
+
knowledge_graph.invalidate(edge_id: edge_id, reason: reason)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def kg_stats(session_id:, domain_id: nil, scope_context: nil)
|
|
178
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
179
|
+
knowledge_graph.stats(scope_ids: resolved[:scope_ids])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def fact_check(session_id:, text:, domain_id: nil, scope_context: nil, **options)
|
|
183
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
184
|
+
@fact_check.check(session_id: session_id, text: text, scope_ids: resolved[:scope_ids], **options)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def brief(session_id:, query: nil, domain_id: nil, scope_context: nil)
|
|
188
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
189
|
+
@briefing.brief(
|
|
190
|
+
session_id: session_id, query: query, scope_ids: resolved[:scope_ids], domain_id: resolved[:domain_id],
|
|
191
|
+
scope_context: public_scope_context(resolved)
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def wake_up(session_id:, domain_id: nil, scope_context: nil)
|
|
196
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
197
|
+
@briefing.wake_up(
|
|
198
|
+
session_id: session_id, scope_ids: resolved[:scope_ids], domain_id: resolved[:domain_id],
|
|
199
|
+
scope_context: public_scope_context(resolved)
|
|
200
|
+
)
|
|
52
201
|
end
|
|
202
|
+
def resume(...) = @briefing.resume(...)
|
|
53
203
|
|
|
54
|
-
def commit_turn(session_id:, turn_events:)
|
|
204
|
+
def commit_turn(session_id:, turn_events:, domain_id: nil, scope_context: nil)
|
|
55
205
|
started_at = monotonic_time
|
|
56
206
|
now = clock.call
|
|
57
|
-
|
|
207
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
208
|
+
turn = event_store.append_turn(
|
|
209
|
+
session_id: session_id, turn_events: turn_events, created_at: now, domain_id: resolved[:domain_id]
|
|
210
|
+
)
|
|
58
211
|
entity_frequencies = event_store.entity_frequencies(
|
|
59
212
|
session_id: session_id,
|
|
60
213
|
window_turns: config.retention.dig(:entity_gate, :window_turns) || 20
|
|
61
214
|
)
|
|
62
215
|
|
|
63
216
|
extracted = extractor.extract(session_id: session_id, turn: turn, entity_frequencies: entity_frequencies)
|
|
217
|
+
extracted = route_scoped_records(extracted, resolved, session_id)
|
|
64
218
|
write_result = memory_store.upsert(extracted)
|
|
219
|
+
edges_written = persist_edges(session_id: session_id, turn_id: turn[:id], edges: extracted[:edges])
|
|
65
220
|
turn_count = event_store.turns_count(session_id: session_id)
|
|
66
221
|
recent_turns = event_store.recent_turns(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
|
|
67
222
|
stage_event = extracted[:items].any? { |item| item[:type] == 'decisions' || (item[:type] == 'tasks' && item.dig(:value_json, :status) == 'done') }
|
|
@@ -69,7 +224,7 @@ module SmartBrain
|
|
|
69
224
|
session_id: session_id,
|
|
70
225
|
turn_count: turn_count,
|
|
71
226
|
recent_turns: recent_turns,
|
|
72
|
-
memory_items: memory_store.active_items(
|
|
227
|
+
memory_items: memory_store.active_items(scope_ids: [resolved[:default_write_scope][:scope_id]]),
|
|
73
228
|
stage_event: stage_event
|
|
74
229
|
)
|
|
75
230
|
|
|
@@ -77,12 +232,18 @@ module SmartBrain
|
|
|
77
232
|
ok: true,
|
|
78
233
|
commit_id: SecureRandom.uuid,
|
|
79
234
|
session_id: session_id,
|
|
235
|
+
domain_id: resolved[:domain_id],
|
|
236
|
+
scope_context: public_scope_context(resolved),
|
|
80
237
|
turn_id: turn[:id],
|
|
81
238
|
memory_written: write_result,
|
|
82
239
|
summary: summary,
|
|
83
240
|
explain: {
|
|
84
241
|
retention: extracted.fetch(:explain, []),
|
|
85
242
|
conflicts: write_result.fetch(:conflicts, []),
|
|
243
|
+
scope_routing: write_result.fetch(:items, []).map do |item|
|
|
244
|
+
item.slice(:id, :type, :key, :scope, :source_session_id)
|
|
245
|
+
end,
|
|
246
|
+
edges: edges_written,
|
|
86
247
|
summary: {
|
|
87
248
|
triggered: summary[:triggered],
|
|
88
249
|
reason: summary[:trigger_reason]
|
|
@@ -103,8 +264,9 @@ module SmartBrain
|
|
|
103
264
|
result
|
|
104
265
|
end
|
|
105
266
|
|
|
106
|
-
def compose_context(session_id:, user_message:, agent_state: {})
|
|
267
|
+
def compose_context(session_id:, user_message:, agent_state: {}, domain_id: nil, scope_context: nil)
|
|
107
268
|
started_at = monotonic_time
|
|
269
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
108
270
|
recent_turns = event_store.recent_turns(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
|
|
109
271
|
refs = event_store.recent_refs(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
|
|
110
272
|
request_id = SecureRandom.uuid
|
|
@@ -116,15 +278,23 @@ module SmartBrain
|
|
|
116
278
|
recent_turns: recent_turns,
|
|
117
279
|
refs: refs
|
|
118
280
|
)
|
|
281
|
+
plan[:scope_context] = public_scope_context(resolved)
|
|
282
|
+
plan[:debug] = (plan[:debug] || {}).merge(
|
|
283
|
+
caller: { app: 'smart_brain', domain_id: resolved[:domain_id], session_id: session_id }
|
|
284
|
+
)
|
|
119
285
|
Contracts::RetrievalPlan.validate!(plan)
|
|
120
286
|
|
|
121
287
|
memory_evidence = memory_retriever.retrieve(
|
|
122
288
|
query: user_message,
|
|
123
|
-
|
|
289
|
+
memory_store: memory_store,
|
|
290
|
+
session_id: session_id,
|
|
291
|
+
scope_ids: resolved[:scope_ids],
|
|
124
292
|
recent_turns: recent_turns,
|
|
125
|
-
entities: memory_store.entities(
|
|
293
|
+
entities: memory_store.entities(scope_ids: resolved[:scope_ids]),
|
|
126
294
|
refs: refs
|
|
127
295
|
)
|
|
296
|
+
conflict_result = conflict_resolver.resolve(memory_evidence)
|
|
297
|
+
memory_evidence = conflict_result[:selected]
|
|
128
298
|
|
|
129
299
|
resource_pack = if plan.dig(:resource_retrieval, :enabled)
|
|
130
300
|
smart_rag_client.retrieve(plan)
|
|
@@ -147,15 +317,23 @@ module SmartBrain
|
|
|
147
317
|
resource_evidence: resource_pack.fetch(:evidences, [])
|
|
148
318
|
)
|
|
149
319
|
merged_bundle[:ignored_fields] = Array(merged_bundle[:ignored_fields]) + Array(resource_pack.dig(:explain, :ignored_fields))
|
|
320
|
+
merged_bundle[:scope_filter_warnings] = Array(resource_pack[:warnings]).select do |warning|
|
|
321
|
+
warning.to_s.include?('scope')
|
|
322
|
+
end
|
|
150
323
|
|
|
151
324
|
context = composer.compose(
|
|
152
325
|
session_id: session_id,
|
|
326
|
+
domain_id: resolved[:domain_id],
|
|
327
|
+
scope_context: public_scope_context(resolved),
|
|
153
328
|
user_message: user_message,
|
|
154
329
|
plan: plan,
|
|
155
330
|
plan_id: resource_pack[:plan_id],
|
|
156
331
|
summary: consolidator.latest_summary(session_id),
|
|
157
332
|
recent_turns: recent_turns,
|
|
158
|
-
evidence_bundle: merged_bundle
|
|
333
|
+
evidence_bundle: merged_bundle.merge(
|
|
334
|
+
shadowed: conflict_result[:shadowed], scope_stats: conflict_result[:scope_stats],
|
|
335
|
+
scope_budget: conflict_result[:scope_budget], scope_filter_warnings: merged_bundle[:scope_filter_warnings]
|
|
336
|
+
)
|
|
159
337
|
)
|
|
160
338
|
Contracts::ContextPackage.validate!(context)
|
|
161
339
|
|
|
@@ -170,24 +348,118 @@ module SmartBrain
|
|
|
170
348
|
ignored_fields: context.dig(:debug, :ignored),
|
|
171
349
|
token_used: token_budget[:used_estimate] || 0,
|
|
172
350
|
token_limit: token_budget[:limit] || config.composition.fetch(:token_limit, 8192),
|
|
173
|
-
took_ms: took_ms
|
|
351
|
+
took_ms: took_ms,
|
|
352
|
+
scope_stats: conflict_result[:scope_stats],
|
|
353
|
+
shadowed_count: conflict_result[:shadowed].size,
|
|
354
|
+
scope_filter_warnings: merged_bundle[:scope_filter_warnings]
|
|
174
355
|
)
|
|
175
356
|
context
|
|
176
357
|
end
|
|
177
358
|
|
|
359
|
+
def search_memory(session_id:, query:, limit: nil, domain_id: nil, scope_context: nil)
|
|
360
|
+
limit ||= config.retrieval.fetch(:top_k, 30)
|
|
361
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
362
|
+
result = memory_store.search_memory(query: query, scope_ids: resolved[:scope_ids], limit: limit * resolved[:scope_ids].size)
|
|
363
|
+
conflict_resolver.resolve(result)[:selected].first(limit)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def memory_item(id:)
|
|
367
|
+
memory_store.find_item(id: id)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def storage_backend
|
|
371
|
+
config.storage_backend
|
|
372
|
+
end
|
|
373
|
+
|
|
178
374
|
def diagnostics
|
|
179
|
-
tracker.snapshot.merge(
|
|
375
|
+
tracker.snapshot.merge(
|
|
376
|
+
backend: storage_backend,
|
|
377
|
+
turns: event_store.all_turns(session_id: nil),
|
|
378
|
+
summaries: memory_store.all_summaries
|
|
379
|
+
)
|
|
180
380
|
end
|
|
181
381
|
|
|
182
382
|
private
|
|
183
383
|
|
|
184
384
|
attr_reader :config, :clock, :event_store, :memory_store, :extractor, :consolidator,
|
|
185
|
-
:
|
|
385
|
+
:scope_registry, :scope_resolver, :conflict_resolver, :planner, :memory_retriever,
|
|
386
|
+
:smart_rag_client, :merger, :composer, :tracker, :model_provider, :lifecycle, :knowledge_graph
|
|
387
|
+
|
|
388
|
+
def resolve_scopes(domain_id:, session_id:, scope_context:)
|
|
389
|
+
scope_registry.resolve(
|
|
390
|
+
domain_id: domain_id, session_id: session_id, scope_context: scope_context,
|
|
391
|
+
allowed_types: config.allowed_scope_types
|
|
392
|
+
)
|
|
393
|
+
rescue ArgumentError => e
|
|
394
|
+
tracker.log_scope_validation_failure(e)
|
|
395
|
+
raise
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def public_scope_context(resolved)
|
|
399
|
+
{ read: resolved[:read], write: resolved[:write], default_write: resolved[:default_write] }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def route_scoped_records(extracted, resolved, session_id)
|
|
403
|
+
scope_resolver.route(extracted: extracted, resolved_context: resolved, source_session_id: session_id)
|
|
404
|
+
rescue ArgumentError => e
|
|
405
|
+
tracker.log_scope_validation_failure(e)
|
|
406
|
+
raise
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def normalize_scope_ref(ref)
|
|
410
|
+
Contracts::ScopeRef.normalize(ref, allowed_types: config.allowed_scope_types)
|
|
411
|
+
rescue ArgumentError => e
|
|
412
|
+
tracker.log_scope_validation_failure(e)
|
|
413
|
+
raise
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def scope_validation_failure!(message)
|
|
417
|
+
error = ArgumentError.new(message)
|
|
418
|
+
tracker.log_scope_validation_failure(error)
|
|
419
|
+
raise error
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
|
|
423
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
424
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
425
|
+
return item unless domain_id || session_id || scope_context
|
|
426
|
+
scope_validation_failure!('session_id is required to validate memory item access') if session_id.to_s.empty?
|
|
427
|
+
|
|
428
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
429
|
+
scope_validation_failure!("memory item is outside the requested domain/scopes: #{memory_item_id}") unless resolved[:scope_ids].include?(item[:scope_id])
|
|
430
|
+
|
|
431
|
+
item
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def authorize_edge!(edge_id, domain_id:, session_id:, scope_context:)
|
|
435
|
+
edge = memory_store.find_edge(id: edge_id)
|
|
436
|
+
raise ArgumentError, "edge not found: #{edge_id}" unless edge
|
|
437
|
+
return edge unless domain_id || session_id || scope_context
|
|
438
|
+
scope_validation_failure!('session_id is required to validate edge access') if session_id.to_s.empty?
|
|
439
|
+
|
|
440
|
+
resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
|
|
441
|
+
scope_validation_failure!("edge is outside the requested domain/scopes: #{edge_id}") unless resolved[:scope_ids].include?(edge[:scope_id])
|
|
442
|
+
|
|
443
|
+
edge
|
|
444
|
+
end
|
|
186
445
|
|
|
187
446
|
def monotonic_time
|
|
188
447
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
189
448
|
end
|
|
190
449
|
|
|
450
|
+
def persist_edges(session_id:, turn_id:, edges:)
|
|
451
|
+
return [] unless edges&.any?
|
|
452
|
+
|
|
453
|
+
edges.map do |edge|
|
|
454
|
+
knowledge_graph.add(
|
|
455
|
+
session_id: session_id,
|
|
456
|
+
subject: edge[:subject], predicate: edge[:predicate], object: edge[:object],
|
|
457
|
+
source_turn_id: turn_id, confidence: edge[:confidence], scope_id: edge[:scope_id],
|
|
458
|
+
scope: edge[:scope], source_session_id: edge[:source_session_id]
|
|
459
|
+
)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
191
463
|
def elapsed_ms(start)
|
|
192
464
|
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
193
465
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Scopes
|
|
5
|
+
class ConflictResolver
|
|
6
|
+
DEFAULT_PRIORITY = %w[task project expert global session].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(config:)
|
|
9
|
+
@priority = Array(config.scopes[:conflict_priority] || DEFAULT_PRIORITY).map(&:to_s)
|
|
10
|
+
@max_items_per_scope = Integer(config.scopes.fetch(:max_items_per_scope, 10))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve(evidence)
|
|
14
|
+
selected = []
|
|
15
|
+
shadowed = []
|
|
16
|
+
keyed, unkeyed = evidence.partition { |item| item[:source] == 'memory' && item[:memory_type] && item[:memory_key] }
|
|
17
|
+
|
|
18
|
+
keyed.group_by { |item| [item[:memory_type], item[:memory_key]] }.each_value do |candidates|
|
|
19
|
+
winner = candidates.min_by { |item| winner_rank(item) }
|
|
20
|
+
selected << winner
|
|
21
|
+
candidates.each do |candidate|
|
|
22
|
+
next if candidate.equal?(winner)
|
|
23
|
+
|
|
24
|
+
shadowed << candidate.merge(
|
|
25
|
+
shadow_reason: shadow_reason(winner, candidate),
|
|
26
|
+
shadowed_by: winner[:id]
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
selected.concat(unkeyed)
|
|
31
|
+
|
|
32
|
+
budgeted = []
|
|
33
|
+
counts = Hash.new(0)
|
|
34
|
+
selected.sort_by { |item| -item.fetch(:score, 0.0) }.each do |item|
|
|
35
|
+
scope_key = item[:scope] && "#{item.dig(:scope, :type)}:#{item.dig(:scope, :id)}"
|
|
36
|
+
if scope_key && counts[scope_key] >= @max_items_per_scope
|
|
37
|
+
shadowed << item.merge(shadow_reason: 'scope_budget')
|
|
38
|
+
else
|
|
39
|
+
counts[scope_key] += 1 if scope_key
|
|
40
|
+
budgeted << item
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
selected: budgeted,
|
|
46
|
+
shadowed: shadowed,
|
|
47
|
+
scope_stats: counts,
|
|
48
|
+
scope_budget: { max_items_per_scope: @max_items_per_scope }
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def winner_rank(item)
|
|
55
|
+
pinned = item[:tier].to_s == 'dao_tian' ? 0 : 1
|
|
56
|
+
scope_priority = @priority.index(item.dig(:scope, :type).to_s) || @priority.length
|
|
57
|
+
[pinned, scope_priority, -item.fetch(:score, 0.0)]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def shadow_reason(winner, candidate)
|
|
61
|
+
return 'dao_tian_pinned' if winner[:tier].to_s == 'dao_tian' && candidate[:tier].to_s != 'dao_tian'
|
|
62
|
+
|
|
63
|
+
"more_specific_scope:#{winner.dig(:scope, :type)}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative '../contracts/scope_context'
|
|
5
|
+
|
|
6
|
+
module SmartBrain
|
|
7
|
+
module Scopes
|
|
8
|
+
class Registry
|
|
9
|
+
def initialize(db: nil, clock: -> { Time.now.utc })
|
|
10
|
+
@db = db
|
|
11
|
+
@clock = clock
|
|
12
|
+
@domains = {}
|
|
13
|
+
@sessions = {}
|
|
14
|
+
@scopes = {}
|
|
15
|
+
@links = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolve(domain_id:, session_id:, scope_context:, allowed_types: Contracts::ScopeRef::DEFAULT_ALLOWED_TYPES)
|
|
19
|
+
context = Contracts::ScopeContext.normalize(
|
|
20
|
+
domain_id: domain_id, session_id: session_id, scope_context: scope_context, allowed_types: allowed_types
|
|
21
|
+
)
|
|
22
|
+
records = db ? resolve_postgres(context, session_id.to_s) : resolve_memory(context, session_id.to_s)
|
|
23
|
+
default_key = scope_key(context[:default_write])
|
|
24
|
+
|
|
25
|
+
context.merge(
|
|
26
|
+
scopes: records,
|
|
27
|
+
scope_ids: records.map { |record| record[:scope_id] },
|
|
28
|
+
default_write_scope: records.find { |record| scope_key(record) == default_key }
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :db, :clock, :domains, :sessions, :scopes, :links
|
|
35
|
+
|
|
36
|
+
def resolve_memory(context, session_id)
|
|
37
|
+
domain_id = context[:domain_id]
|
|
38
|
+
domains[domain_id] ||= { id: domain_id, metadata: {}, created_at: clock.call.iso8601 }
|
|
39
|
+
existing_domain = sessions[session_id]
|
|
40
|
+
if existing_domain && existing_domain != domain_id
|
|
41
|
+
raise ArgumentError, "session #{session_id} belongs to domain #{existing_domain}"
|
|
42
|
+
end
|
|
43
|
+
sessions[session_id] = domain_id
|
|
44
|
+
|
|
45
|
+
context[:read].each_with_index.map do |ref, priority|
|
|
46
|
+
key = [domain_id, ref[:type], ref[:id]]
|
|
47
|
+
scope = scopes[key] ||= scope_record(domain_id, ref)
|
|
48
|
+
links[[session_id, scope[:scope_id]]] = {
|
|
49
|
+
access_mode: writable?(context, ref) ? 'read_write' : 'read',
|
|
50
|
+
priority: priority
|
|
51
|
+
}
|
|
52
|
+
scope
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_postgres(context, session_id)
|
|
57
|
+
domain_id = context[:domain_id]
|
|
58
|
+
records = []
|
|
59
|
+
db.transaction do
|
|
60
|
+
ensure_domain(domain_id)
|
|
61
|
+
ensure_session(session_id, domain_id)
|
|
62
|
+
records = context[:read].each_with_index.map do |ref, priority|
|
|
63
|
+
scope = ensure_scope(domain_id, ref)
|
|
64
|
+
ensure_link(session_id, scope[:id], priority, writable?(context, ref) ? 'read_write' : 'read')
|
|
65
|
+
scope_record_from_row(scope)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
records
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ensure_domain(domain_id)
|
|
72
|
+
return if db[:domains].where(id: domain_id).first
|
|
73
|
+
|
|
74
|
+
now = clock.call
|
|
75
|
+
db[:domains].insert(id: domain_id, metadata_json: Sequel.pg_jsonb({}), created_at: now, updated_at: now)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ensure_session(session_id, domain_id)
|
|
79
|
+
row = db[:sessions].where(id: session_id).first
|
|
80
|
+
if row && row[:domain_id] != domain_id
|
|
81
|
+
raise ArgumentError, "session #{session_id} belongs to domain #{row[:domain_id]}"
|
|
82
|
+
end
|
|
83
|
+
return if row
|
|
84
|
+
|
|
85
|
+
db[:sessions].insert(id: session_id, domain_id: domain_id, created_at: clock.call, metadata_json: Sequel.pg_jsonb({}))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ensure_scope(domain_id, ref)
|
|
89
|
+
dataset = db[:memory_scopes].where(domain_id: domain_id, scope_type: ref[:type], external_id: ref[:id])
|
|
90
|
+
dataset.first || begin
|
|
91
|
+
now = clock.call
|
|
92
|
+
id = SecureRandom.uuid
|
|
93
|
+
db[:memory_scopes].insert(
|
|
94
|
+
id: id, domain_id: domain_id, scope_type: ref[:type], external_id: ref[:id],
|
|
95
|
+
metadata_json: Sequel.pg_jsonb({}), created_at: now, updated_at: now
|
|
96
|
+
)
|
|
97
|
+
db[:memory_scopes].where(id: id).first
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ensure_link(session_id, scope_id, priority, access_mode)
|
|
102
|
+
db[:session_scope_links].insert_conflict(
|
|
103
|
+
target: %i[session_id scope_id], update: { access_mode: access_mode, priority: priority }
|
|
104
|
+
).insert(session_id: session_id, scope_id: scope_id, access_mode: access_mode, priority: priority, created_at: clock.call)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def scope_record(domain_id, ref)
|
|
108
|
+
{
|
|
109
|
+
scope_id: legacy_scope?(domain_id, ref) ? "legacy:session:#{ref[:id]}" : SecureRandom.uuid,
|
|
110
|
+
domain_id: domain_id,
|
|
111
|
+
type: ref[:type],
|
|
112
|
+
id: ref[:id]
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def scope_record_from_row(row)
|
|
117
|
+
{ scope_id: row[:id], domain_id: row[:domain_id], type: row[:scope_type], id: row[:external_id] }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def scope_key(ref)
|
|
121
|
+
"#{ref[:type]}:#{ref[:id]}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def writable?(context, ref)
|
|
125
|
+
context[:write].any? { |candidate| scope_key(candidate) == scope_key(ref) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def legacy_scope?(domain_id, ref)
|
|
129
|
+
domain_id == Contracts::ScopeContext::LEGACY_DOMAIN_ID && ref[:type] == 'session'
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Scopes
|
|
5
|
+
class Resolver
|
|
6
|
+
def route(extracted:, resolved_context:, source_session_id:)
|
|
7
|
+
extracted.merge(
|
|
8
|
+
domain_id: resolved_context[:domain_id],
|
|
9
|
+
items: Array(extracted[:items]).map { |item| route_record(item, resolved_context, source_session_id) },
|
|
10
|
+
edges: Array(extracted[:edges]).map { |edge| route_record(edge, resolved_context, source_session_id) }
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def route_record(record, context, source_session_id)
|
|
17
|
+
requested = record[:scope_ref] || context[:default_write]
|
|
18
|
+
normalized = Contracts::ScopeRef.normalize(requested)
|
|
19
|
+
writable = context[:write].any? { |candidate| candidate[:type] == normalized[:type] && candidate[:id] == normalized[:id] }
|
|
20
|
+
raise ArgumentError, "scope is not authorized by scope_context.write: #{normalized[:type]}:#{normalized[:id]}" unless writable
|
|
21
|
+
|
|
22
|
+
scope = context[:scopes].find { |candidate| candidate[:type] == normalized[:type] && candidate[:id] == normalized[:id] }
|
|
23
|
+
|
|
24
|
+
record.merge(
|
|
25
|
+
scope_id: scope[:scope_id],
|
|
26
|
+
scope: { type: scope[:type], id: scope[:id] },
|
|
27
|
+
source_session_id: source_session_id
|
|
28
|
+
).tap { |routed| routed.delete(:scope_ref) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|