smart_brain 0.1.1 → 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 +2 -2
- data/README.md +356 -62
- data/config/brain.yml +69 -1
- data/conversation_demo.rb +5 -5
- 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 +2 -2
- 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 +1 -1
- data/lib/smart_brain.rb +49 -4
- metadata +88 -36
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'tiers'
|
|
5
|
+
|
|
6
|
+
module SmartBrain
|
|
7
|
+
module Governance
|
|
8
|
+
# Stage-1 knowledge lifecycle (mempal parity):
|
|
9
|
+
#
|
|
10
|
+
# evidence → distill(candidate) → gate → promote(promoted) → demote/retire
|
|
11
|
+
#
|
|
12
|
+
# Operates over a MemoryStore (independent of backend). distill creates a
|
|
13
|
+
# candidate knowledge item at tier dao_ren|qi; gate is a read-only readiness
|
|
14
|
+
# check; promote/demote are evidence-backed transitions that append to the
|
|
15
|
+
# knowledge_events audit trail. Orthogonal to the conflict `status`.
|
|
16
|
+
class Lifecycle
|
|
17
|
+
DEMOTE_REASONS = %w[contradicted obsolete superseded].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(memory_store:, config:, model_provider: nil)
|
|
20
|
+
@memory_store = memory_store
|
|
21
|
+
@config = config
|
|
22
|
+
@model_provider = model_provider
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def distill(session_id:, statement:, content: nil, tier:, supporting_refs: [], domain: nil, field: nil,
|
|
26
|
+
reviewer: nil, key: nil, scope_id: nil, scope: nil)
|
|
27
|
+
tier = tier.to_s
|
|
28
|
+
raise ArgumentError, "tier must be one of #{Tiers::DISTILLABLE.join('/')}" unless Tiers.distillable?(tier)
|
|
29
|
+
|
|
30
|
+
statement = draft_statement(statement, supporting_refs: supporting_refs, content: content) if statement.nil? || statement.strip.empty?
|
|
31
|
+
raise ArgumentError, 'statement is required (provide one or wire an LLM provider)' if statement.nil? || statement.strip.empty?
|
|
32
|
+
|
|
33
|
+
item = {
|
|
34
|
+
type: 'knowledge',
|
|
35
|
+
key: key || "knowledge:#{tier}:#{slugify(statement)}",
|
|
36
|
+
value_json: {
|
|
37
|
+
statement: statement,
|
|
38
|
+
content: content,
|
|
39
|
+
tier: tier,
|
|
40
|
+
domain: domain,
|
|
41
|
+
field: field,
|
|
42
|
+
supporting_refs: Array(supporting_refs)
|
|
43
|
+
},
|
|
44
|
+
confidence: 0.5,
|
|
45
|
+
status: 'active',
|
|
46
|
+
tier: tier,
|
|
47
|
+
lifecycle_status: 'candidate',
|
|
48
|
+
source_turn_id: nil,
|
|
49
|
+
updated_at: Time.now.utc.iso8601
|
|
50
|
+
}
|
|
51
|
+
record = memory_store.create_item(session_id: session_id, item: item, scope_id: scope_id, scope: scope)
|
|
52
|
+
memory_store.record_event(
|
|
53
|
+
memory_item_id: record[:id], event_type: 'distill',
|
|
54
|
+
from_lifecycle: nil, to_lifecycle: 'candidate',
|
|
55
|
+
reason: 'distilled from evidence', reviewer: reviewer,
|
|
56
|
+
evidence_refs: Array(supporting_refs)
|
|
57
|
+
)
|
|
58
|
+
{ memory_item: record, event: last_event(record[:id]) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Read-only promotion readiness check.
|
|
62
|
+
def gate(memory_item_id:)
|
|
63
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
64
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
65
|
+
|
|
66
|
+
refs = Array(item.dig(:value_json, :supporting_refs) || item.dig(:value_json, 'supporting_refs'))
|
|
67
|
+
min = min_supporting_refs
|
|
68
|
+
reviewer = item.dig(:value_json, :reviewer) || item.dig(:value_json, 'reviewer')
|
|
69
|
+
checks = {
|
|
70
|
+
lifecycle: item[:lifecycle_status],
|
|
71
|
+
supporting_refs: refs.size,
|
|
72
|
+
min_required: min,
|
|
73
|
+
reviewer: reviewer
|
|
74
|
+
}
|
|
75
|
+
ready = item[:lifecycle_status] == 'candidate' && (refs.size >= min || reviewer == 'human')
|
|
76
|
+
{ ready: ready, target_status: 'promoted', checks: checks }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def promote(memory_item_id:, verification_refs:, reason:, reviewer: nil, force: false)
|
|
80
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
81
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
82
|
+
|
|
83
|
+
decision = gate(memory_item_id: memory_item_id)
|
|
84
|
+
if !decision[:ready] && !force
|
|
85
|
+
raise LifecycleGateError, "promotion gate failed: #{decision[:checks].inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
from = item[:lifecycle_status]
|
|
89
|
+
updated = memory_store.set_lifecycle(
|
|
90
|
+
id: memory_item_id, lifecycle_status: 'promoted',
|
|
91
|
+
merge_value: { verification_refs: Array(verification_refs), reviewer: reviewer }
|
|
92
|
+
)
|
|
93
|
+
memory_store.record_event(
|
|
94
|
+
memory_item_id: memory_item_id, event_type: 'promote',
|
|
95
|
+
from_lifecycle: from, to_lifecycle: 'promoted',
|
|
96
|
+
reason: reason, reviewer: reviewer, evidence_refs: Array(verification_refs)
|
|
97
|
+
)
|
|
98
|
+
{ memory_item: updated, event: last_event(memory_item_id) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def demote(memory_item_id:, evidence_refs:, reason:, reason_type:, reviewer: nil)
|
|
102
|
+
raise ArgumentError, "reason_type must be one of #{DEMOTE_REASONS.join('/')}" unless DEMOTE_REASONS.include?(reason_type.to_s)
|
|
103
|
+
|
|
104
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
105
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
106
|
+
|
|
107
|
+
from = item[:lifecycle_status]
|
|
108
|
+
updated = memory_store.set_lifecycle(
|
|
109
|
+
id: memory_item_id, lifecycle_status: 'demoted',
|
|
110
|
+
merge_value: { demotion_reason: reason, reason_type: reason_type }
|
|
111
|
+
)
|
|
112
|
+
memory_store.record_event(
|
|
113
|
+
memory_item_id: memory_item_id, event_type: 'demote',
|
|
114
|
+
from_lifecycle: from, to_lifecycle: 'demoted',
|
|
115
|
+
reason: reason, reason_type: reason_type, reviewer: reviewer, evidence_refs: Array(evidence_refs)
|
|
116
|
+
)
|
|
117
|
+
{ memory_item: updated, event: last_event(memory_item_id) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def promote_to_scope(source_item:, target_scope:, session_id:, verification_refs:, reason:, reviewer: nil)
|
|
121
|
+
refs = Array(verification_refs)
|
|
122
|
+
unless reviewer == 'human' || refs.size >= min_supporting_refs
|
|
123
|
+
raise LifecycleGateError, "scope promotion requires human review or #{min_supporting_refs} verification refs"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
provenance = {
|
|
127
|
+
source_memory_item_id: source_item[:id],
|
|
128
|
+
source_scope: source_item[:scope],
|
|
129
|
+
target_scope: { type: target_scope[:type], id: target_scope[:id] },
|
|
130
|
+
source_session_id: source_item[:source_session_id] || source_item[:session_id],
|
|
131
|
+
verification_refs: refs,
|
|
132
|
+
reviewer: reviewer,
|
|
133
|
+
reason: reason
|
|
134
|
+
}
|
|
135
|
+
item = source_item.slice(:type, :key, :confidence, :tier).merge(
|
|
136
|
+
value_json: (source_item[:value_json] || {}).merge(provenance: provenance),
|
|
137
|
+
status: 'active', lifecycle_status: 'promoted', source_turn_id: source_item[:source_turn_id],
|
|
138
|
+
source_message_id: source_item[:source_message_id],
|
|
139
|
+
source_session_id: source_item[:source_session_id] || source_item[:session_id],
|
|
140
|
+
updated_at: Time.now.utc.iso8601
|
|
141
|
+
)
|
|
142
|
+
target = memory_store.create_item(
|
|
143
|
+
session_id: session_id, item: item, scope_id: target_scope[:scope_id],
|
|
144
|
+
scope: { type: target_scope[:type], id: target_scope[:id] }
|
|
145
|
+
)
|
|
146
|
+
event = memory_store.record_event(
|
|
147
|
+
memory_item_id: target[:id], event_type: 'promote_to_scope',
|
|
148
|
+
from_lifecycle: source_item[:lifecycle_status], to_lifecycle: 'promoted',
|
|
149
|
+
reason: reason, reviewer: reviewer, evidence_refs: refs
|
|
150
|
+
)
|
|
151
|
+
target = memory_store.set_lifecycle(
|
|
152
|
+
id: target[:id], lifecycle_status: 'promoted',
|
|
153
|
+
merge_value: { provenance: provenance.merge(promotion_event_id: event[:id]) }
|
|
154
|
+
)
|
|
155
|
+
{ source_memory_item: source_item, memory_item: target, event: event }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def retract(memory_item_id:, evidence_refs:, reason:, reviewer: nil)
|
|
159
|
+
item = memory_store.find_item(id: memory_item_id)
|
|
160
|
+
raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
|
|
161
|
+
|
|
162
|
+
updated = memory_store.set_status(id: memory_item_id, status: 'retracted', merge_value: { retraction_reason: reason })
|
|
163
|
+
event = memory_store.record_event(
|
|
164
|
+
memory_item_id: memory_item_id, event_type: 'retract',
|
|
165
|
+
from_lifecycle: item[:lifecycle_status], to_lifecycle: item[:lifecycle_status],
|
|
166
|
+
reason: reason, reviewer: reviewer, evidence_refs: Array(evidence_refs)
|
|
167
|
+
)
|
|
168
|
+
{ memory_item: updated, event: event }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def lineage(memory_item_id:)
|
|
172
|
+
lineage = []
|
|
173
|
+
current = memory_store.find_item(id: memory_item_id)
|
|
174
|
+
while current
|
|
175
|
+
lineage << current
|
|
176
|
+
source_id = current.dig(:value_json, :provenance, :source_memory_item_id)
|
|
177
|
+
break unless source_id
|
|
178
|
+
|
|
179
|
+
current = memory_store.find_item(id: source_id)
|
|
180
|
+
end
|
|
181
|
+
lineage
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def events(memory_item_id:)
|
|
185
|
+
memory_store.events_for(memory_item_id: memory_item_id)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
attr_reader :memory_store, :config, :model_provider
|
|
191
|
+
|
|
192
|
+
def min_supporting_refs
|
|
193
|
+
config.lifecycle.fetch(:min_supporting_refs, 2) rescue 2
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def last_event(memory_item_id)
|
|
197
|
+
memory_store.events_for(memory_item_id: memory_item_id).last
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def draft_statement(statement, supporting_refs:, content:)
|
|
201
|
+
return statement unless model_provider&.llm?
|
|
202
|
+
return nil if Array(supporting_refs).empty?
|
|
203
|
+
|
|
204
|
+
refs_text = supporting_refs_for(supporting_refs).map { |i| "- #{i[:key]}: #{i.dig(:value_json, :statement) || i[:value_json]}" }.join("\n")
|
|
205
|
+
result = model_provider.complete(
|
|
206
|
+
prompt: "Distill ONE concise knowledge statement (<= 1 line) from these observations; reply with only the statement.\n#{refs_text}",
|
|
207
|
+
temperature: 0.2, max_tokens: 120
|
|
208
|
+
)
|
|
209
|
+
result[:error] ? nil : result[:text]
|
|
210
|
+
rescue StandardError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def supporting_refs_for(refs)
|
|
215
|
+
Array(refs).filter_map { |id| memory_store.find_item(id: id) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def slugify(text)
|
|
219
|
+
text.to_s.downcase.scan(/[[:alnum:]]+/).first(6).join('-')[0, 60]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
class LifecycleGateError < StandardError; end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Governance
|
|
5
|
+
# Mind-model tiers (mempal parity): dao_tian 天道 / dao_ren 人道 / shu 术 /
|
|
6
|
+
# qi 器 / evidence 证据. Lower ORDER index = higher assembly priority.
|
|
7
|
+
module Tiers
|
|
8
|
+
ORDER = %w[dao_tian dao_ren shu qi evidence].freeze
|
|
9
|
+
PRIORITY = ORDER.each_with_index.to_h.freeze
|
|
10
|
+
|
|
11
|
+
# Default tier per memory type. dao_tian is never auto-assigned — it is
|
|
12
|
+
# reserved for user-pinned immutable principles.
|
|
13
|
+
DEFAULT_TIER_BY_TYPE = {
|
|
14
|
+
'profile' => 'dao_ren',
|
|
15
|
+
'decisions' => 'dao_ren',
|
|
16
|
+
'patterns' => 'dao_ren',
|
|
17
|
+
'goals' => 'shu',
|
|
18
|
+
'tasks' => 'shu',
|
|
19
|
+
'preferences' => 'qi',
|
|
20
|
+
'cases' => 'qi',
|
|
21
|
+
'entities' => 'evidence',
|
|
22
|
+
'events' => 'evidence'
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Only dao_ren / qi knowledge can enter the candidate stage via distill
|
|
26
|
+
# (mempal rule: dao_tian and shu are not open to candidate state).
|
|
27
|
+
DISTILLABLE = %w[dao_ren qi].freeze
|
|
28
|
+
|
|
29
|
+
# lifecycle statuses that participate in automatic context assembly.
|
|
30
|
+
CONTEXT_LIFECYCLE = %w[raw promoted].freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def tier_for(type)
|
|
35
|
+
DEFAULT_TIER_BY_TYPE.fetch(type.to_s, 'evidence')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def priority(tier)
|
|
39
|
+
PRIORITY.fetch(tier.to_s, PRIORITY.fetch('evidence'))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def distillable?(tier)
|
|
43
|
+
DISTILLABLE.include?(tier.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def valid?(tier)
|
|
47
|
+
PRIORITY.key?(tier.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dao_tian_limit(config)
|
|
51
|
+
(config.tiers.fetch(:dao_tian_limit, 1) rescue 1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sort by tier priority asc (dao_tian first); stable on equal priority.
|
|
55
|
+
def sort_by_tier(items)
|
|
56
|
+
items.sort_by.with_index { |i, idx| [priority(i[:tier] || i['tier'] || 'evidence'), idx] }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../governance/tiers'
|
|
4
|
+
|
|
3
5
|
module SmartBrain
|
|
4
6
|
module MemoryExtractor
|
|
5
7
|
class Extractor
|
|
@@ -16,11 +18,14 @@ module SmartBrain
|
|
|
16
18
|
collect(items, explain, events[:decisions], type: 'decisions', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
|
|
17
19
|
collect(items, explain, events[:goals], type: 'goals', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
|
|
18
20
|
collect(items, explain, events[:events], type: 'events', confidence: confidence(:tool_derived), source_turn_id: turn[:id])
|
|
21
|
+
collect(items, explain, events[:profile], type: 'profile', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
|
|
22
|
+
collect(items, explain, events[:cases], type: 'cases', confidence: confidence(:tool_derived), source_turn_id: turn[:id])
|
|
23
|
+
collect(items, explain, events[:patterns], type: 'patterns', confidence: confidence(:inferred), source_turn_id: turn[:id])
|
|
19
24
|
|
|
20
25
|
Array(events[:preferences]).each do |preference|
|
|
21
26
|
key = preference.fetch(:key)
|
|
22
27
|
if preference[:confirmed]
|
|
23
|
-
items << build_item(type: 'preferences', key: key, value_json: preference, source_turn_id: turn[:id], confidence: confidence(:user_asserted))
|
|
28
|
+
items << build_item(type: 'preferences', key: key, value_json: preference, source_turn_id: turn[:id], confidence: confidence(:user_asserted), scope_ref: preference[:scope_ref])
|
|
24
29
|
explain << "write preferences:#{key}"
|
|
25
30
|
else
|
|
26
31
|
explain << "skip preferences:#{key} not confirmed"
|
|
@@ -32,7 +37,7 @@ module SmartBrain
|
|
|
32
37
|
canonical = (entity[:canonical] || entity[:name]).to_s.downcase
|
|
33
38
|
should_write = entity[:remember] || entity_structure_signal?(entity) || entity_frequencies[canonical] >= freq_threshold
|
|
34
39
|
if should_write
|
|
35
|
-
items << build_item(type: 'entities', key: key, value_json: entity, source_turn_id: turn[:id], confidence: confidence(:inferred))
|
|
40
|
+
items << build_item(type: 'entities', key: key, value_json: entity, source_turn_id: turn[:id], confidence: confidence(:inferred), scope_ref: entity[:scope_ref])
|
|
36
41
|
explain << "write entities:#{key}"
|
|
37
42
|
else
|
|
38
43
|
explain << "skip entities:#{key} below threshold"
|
|
@@ -40,14 +45,24 @@ module SmartBrain
|
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
Array(events[:retractions]).each do |retraction|
|
|
43
|
-
items << build_item(type: retraction.fetch(:type), key: retraction.fetch(:key), value_json: retraction, source_turn_id: turn[:id], confidence: confidence(:user_asserted), status: 'retracted')
|
|
48
|
+
items << build_item(type: retraction.fetch(:type), key: retraction.fetch(:key), value_json: retraction, source_turn_id: turn[:id], confidence: confidence(:user_asserted), status: 'retracted', scope_ref: retraction[:scope_ref])
|
|
44
49
|
explain << "retract #{retraction.fetch(:type)}:#{retraction.fetch(:key)}"
|
|
45
50
|
end
|
|
46
51
|
|
|
52
|
+
# KG edges: pass-through (subject/predicate/object). Orthogonal to
|
|
53
|
+
# memory_items — runtime persists them to kg_edges via KnowledgeGraph.
|
|
54
|
+
edges = Array(events[:edges]).map do |edge|
|
|
55
|
+
next unless edge[:subject] && edge[:predicate] && edge[:object]
|
|
56
|
+
|
|
57
|
+
{ subject: edge[:subject], predicate: edge[:predicate],
|
|
58
|
+
object: edge[:object], confidence: edge[:confidence], scope_ref: edge[:scope_ref] }
|
|
59
|
+
end.compact
|
|
60
|
+
|
|
47
61
|
{
|
|
48
62
|
session_id: session_id,
|
|
49
63
|
items: items,
|
|
50
|
-
explain: explain
|
|
64
|
+
explain: explain,
|
|
65
|
+
edges: edges
|
|
51
66
|
}
|
|
52
67
|
end
|
|
53
68
|
|
|
@@ -58,12 +73,12 @@ module SmartBrain
|
|
|
58
73
|
def collect(items, explain, raw_items, type:, confidence:, source_turn_id:)
|
|
59
74
|
Array(raw_items).each do |entry|
|
|
60
75
|
key = entry.fetch(:key)
|
|
61
|
-
items << build_item(type: type, key: key, value_json: entry, source_turn_id: source_turn_id, confidence: confidence)
|
|
76
|
+
items << build_item(type: type, key: key, value_json: entry, source_turn_id: source_turn_id, confidence: confidence, scope_ref: entry[:scope_ref])
|
|
62
77
|
explain << "write #{type}:#{key}"
|
|
63
78
|
end
|
|
64
79
|
end
|
|
65
80
|
|
|
66
|
-
def build_item(type:, key:, value_json:, source_turn_id:, confidence:, status: 'active')
|
|
81
|
+
def build_item(type:, key:, value_json:, source_turn_id:, confidence:, status: 'active', scope_ref: nil)
|
|
67
82
|
{
|
|
68
83
|
type: type,
|
|
69
84
|
key: key,
|
|
@@ -71,7 +86,10 @@ module SmartBrain
|
|
|
71
86
|
source_turn_id: source_turn_id,
|
|
72
87
|
confidence: confidence,
|
|
73
88
|
status: status,
|
|
74
|
-
|
|
89
|
+
tier: Governance::Tiers.tier_for(type),
|
|
90
|
+
lifecycle_status: 'raw',
|
|
91
|
+
updated_at: Time.now.utc.iso8601,
|
|
92
|
+
scope_ref: scope_ref
|
|
75
93
|
}
|
|
76
94
|
end
|
|
77
95
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require_relative '../governance/tiers'
|
|
5
|
+
require_relative '../retrievers/exact_retriever'
|
|
4
6
|
|
|
5
7
|
module SmartBrain
|
|
6
8
|
module MemoryStore
|
|
@@ -8,8 +10,12 @@ module SmartBrain
|
|
|
8
10
|
OVERWRITE_TYPES = %w[preferences goals tasks].freeze
|
|
9
11
|
|
|
10
12
|
def initialize
|
|
11
|
-
@
|
|
13
|
+
@by_scope = Hash.new { |h, k| h[k] = [] }
|
|
12
14
|
@entities_index = Hash.new { |h, k| h[k] = [] }
|
|
15
|
+
@summaries = {}
|
|
16
|
+
@events = []
|
|
17
|
+
@edges = []
|
|
18
|
+
@exact = Retrievers::ExactRetriever.new
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
def upsert(extracted)
|
|
@@ -19,7 +25,9 @@ module SmartBrain
|
|
|
19
25
|
conflicts = []
|
|
20
26
|
|
|
21
27
|
items.each do |item|
|
|
22
|
-
|
|
28
|
+
scope_id = item[:scope_id] || legacy_scope_id(session_id)
|
|
29
|
+
scope = item[:scope] || { type: 'session', id: session_id }
|
|
30
|
+
existing = active_item(scope_id: scope_id, type: item[:type], key: item[:key])
|
|
23
31
|
|
|
24
32
|
if existing && item[:status] == 'retracted'
|
|
25
33
|
existing[:status] = 'retracted'
|
|
@@ -32,47 +40,224 @@ module SmartBrain
|
|
|
32
40
|
conflicts << { type: 'overwrite', key: item[:key], previous_memory_item_id: existing[:id] }
|
|
33
41
|
end
|
|
34
42
|
|
|
35
|
-
record = item.merge(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
record = item.merge(
|
|
44
|
+
id: SecureRandom.uuid, status: item[:status] || 'active', session_id: session_id,
|
|
45
|
+
source_session_id: item[:source_session_id] || session_id, scope_id: scope_id, scope: scope
|
|
46
|
+
)
|
|
47
|
+
by_scope[scope_id] << record
|
|
48
|
+
update_entities(scope_id: scope_id, record: record)
|
|
49
|
+
written << record.slice(:id, :type, :key, :status, :confidence, :scope_id, :scope, :source_session_id)
|
|
39
50
|
end
|
|
40
51
|
|
|
41
52
|
{ count: written.size, items: written, conflicts: conflicts }
|
|
42
53
|
end
|
|
43
54
|
|
|
44
|
-
def active_items(session_id:)
|
|
45
|
-
|
|
55
|
+
def active_items(session_id: nil, scope_ids: nil)
|
|
56
|
+
selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
|
|
57
|
+
selected_scope_ids.flat_map { |scope_id| by_scope[scope_id] }
|
|
58
|
+
.select { |item| item[:status] == 'active' && included_lifecycle?(item) }
|
|
46
59
|
end
|
|
47
60
|
|
|
48
|
-
def entities(session_id:)
|
|
49
|
-
entities_index[
|
|
61
|
+
def entities(session_id: nil, scope_ids: nil)
|
|
62
|
+
normalize_scope_ids(session_id, scope_ids).flat_map { |scope_id| entities_index[scope_id] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Substring/exact search over active memory_items. Mirrors the FTS path of
|
|
66
|
+
# MemoryStore::Postgres#search_memory so MemoryRetriever is backend-agnostic.
|
|
67
|
+
def search_memory(query:, session_id: nil, scope_ids: nil, limit:)
|
|
68
|
+
@exact.retrieve(
|
|
69
|
+
query: query, memory_items: active_items(session_id: session_id, scope_ids: scope_ids), recent_turns: [], limit: limit
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def save_summary(session_id:, summary:)
|
|
74
|
+
summaries[session_id] = summary
|
|
75
|
+
summary
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def latest_summary(session_id:)
|
|
79
|
+
summaries[session_id]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def all_summaries
|
|
83
|
+
summaries.each_with_object({}) { |(sid, s), h| h[sid] = s }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# --- knowledge lifecycle -------------------------------------------------
|
|
87
|
+
def create_item(session_id:, item:, scope_id: nil, scope: nil)
|
|
88
|
+
resolved_scope_id = scope_id || item[:scope_id] || legacy_scope_id(session_id)
|
|
89
|
+
record = item.merge(
|
|
90
|
+
id: SecureRandom.uuid, session_id: session_id, source_session_id: item[:source_session_id] || session_id,
|
|
91
|
+
scope_id: resolved_scope_id, scope: scope || item[:scope] || { type: 'session', id: session_id }
|
|
92
|
+
)
|
|
93
|
+
by_scope[resolved_scope_id] << record
|
|
94
|
+
update_entities(scope_id: resolved_scope_id, record: record)
|
|
95
|
+
record
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def find_item(id:)
|
|
99
|
+
by_scope.values.flatten.find { |i| i[:id] == id }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def set_lifecycle(id:, lifecycle_status:, merge_value: nil)
|
|
103
|
+
item = find_item(id: id)
|
|
104
|
+
return nil unless item
|
|
105
|
+
|
|
106
|
+
item[:lifecycle_status] = lifecycle_status
|
|
107
|
+
item[:value_json] = (item[:value_json] || {}).merge(merge_value || {})
|
|
108
|
+
item[:updated_at] = Time.now.utc.iso8601
|
|
109
|
+
item
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def set_status(id:, status:, merge_value: nil)
|
|
113
|
+
item = find_item(id: id)
|
|
114
|
+
return nil unless item
|
|
115
|
+
|
|
116
|
+
item[:status] = status
|
|
117
|
+
item[:value_json] = (item[:value_json] || {}).merge(merge_value || {})
|
|
118
|
+
item[:updated_at] = Time.now.utc.iso8601
|
|
119
|
+
item
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def record_event(memory_item_id:, event_type:, from_lifecycle: nil, to_lifecycle: nil,
|
|
123
|
+
reason: nil, reason_type: nil, reviewer: nil, evidence_refs: [])
|
|
124
|
+
event = {
|
|
125
|
+
id: SecureRandom.uuid,
|
|
126
|
+
memory_item_id: memory_item_id,
|
|
127
|
+
event_type: event_type,
|
|
128
|
+
from_lifecycle: from_lifecycle,
|
|
129
|
+
to_lifecycle: to_lifecycle,
|
|
130
|
+
reason: reason,
|
|
131
|
+
reason_type: reason_type,
|
|
132
|
+
reviewer: reviewer,
|
|
133
|
+
evidence_refs: Array(evidence_refs),
|
|
134
|
+
created_at: Time.now.utc.iso8601
|
|
135
|
+
}
|
|
136
|
+
events << event
|
|
137
|
+
event
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def events_for(memory_item_id:)
|
|
141
|
+
events.select { |e| e[:memory_item_id] == memory_item_id }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- knowledge graph -----------------------------------------------------
|
|
145
|
+
def add_edge(session_id:, edge:, scope_id: nil, scope: nil, source_session_id: nil)
|
|
146
|
+
resolved_scope_id = scope_id || edge[:scope_id] || legacy_scope_id(session_id)
|
|
147
|
+
record = {
|
|
148
|
+
id: SecureRandom.uuid,
|
|
149
|
+
session_id: session_id,
|
|
150
|
+
source_session_id: source_session_id || edge[:source_session_id] || session_id,
|
|
151
|
+
scope_id: resolved_scope_id,
|
|
152
|
+
scope: scope || edge[:scope] || { type: 'session', id: session_id },
|
|
153
|
+
subject: edge[:subject].to_s,
|
|
154
|
+
predicate: edge[:predicate].to_s,
|
|
155
|
+
object: edge[:object].to_s,
|
|
156
|
+
subject_entity_id: resolve_entity_id(nil, edge[:subject], scope_ids: [resolved_scope_id]),
|
|
157
|
+
object_entity_id: resolve_entity_id(nil, edge[:object], scope_ids: [resolved_scope_id]),
|
|
158
|
+
valid_from: edge[:valid_from] || Time.now.utc.iso8601,
|
|
159
|
+
valid_to: edge[:valid_to],
|
|
160
|
+
source_turn_id: edge[:source_turn_id],
|
|
161
|
+
source_memory_item_id: edge[:source_memory_item_id],
|
|
162
|
+
confidence: edge[:confidence] || 0.6,
|
|
163
|
+
status: edge[:status] || 'active',
|
|
164
|
+
meta: edge[:meta] || {},
|
|
165
|
+
created_at: Time.now.utc.iso8601
|
|
166
|
+
}
|
|
167
|
+
edges << record
|
|
168
|
+
record
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def query_edges(session_id: nil, scope_ids: nil, subject: nil, predicate: nil, object: nil, include_invalid: false)
|
|
172
|
+
selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
|
|
173
|
+
edges.select do |e|
|
|
174
|
+
next false unless selected_scope_ids.include?(e[:scope_id])
|
|
175
|
+
next false if !include_invalid && e[:status] != 'active'
|
|
176
|
+
next false if subject && e[:subject].downcase != subject.to_s.downcase
|
|
177
|
+
next false if predicate && e[:predicate].downcase != predicate.to_s.downcase
|
|
178
|
+
next false if object && e[:object].downcase != object.to_s.downcase
|
|
179
|
+
|
|
180
|
+
true
|
|
181
|
+
end.sort_by { |e| e[:valid_from].to_s }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def edges_for_subject(session_id: nil, scope_ids: nil, subject:)
|
|
185
|
+
query_edges(session_id: session_id, scope_ids: scope_ids, subject: subject, include_invalid: true)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def invalidate_edge(id:, reason: nil)
|
|
189
|
+
edge = edges.find { |e| e[:id] == id }
|
|
190
|
+
return nil unless edge
|
|
191
|
+
|
|
192
|
+
edge[:valid_to] = Time.now.utc.iso8601
|
|
193
|
+
edge[:status] = 'invalidated'
|
|
194
|
+
edge[:meta] = (edge[:meta] || {}).merge(invalidated_reason: reason)
|
|
195
|
+
edge
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def find_edge(id:)
|
|
199
|
+
edges.find { |edge| edge[:id] == id }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def edge_stats(session_id: nil, scope_ids: nil)
|
|
203
|
+
selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
|
|
204
|
+
scoped = edges.select { |e| selected_scope_ids.include?(e[:scope_id]) }
|
|
205
|
+
{ total: scoped.size,
|
|
206
|
+
active: scoped.count { |e| e[:status] == 'active' },
|
|
207
|
+
invalidated: scoped.count { |e| e[:status] == 'invalidated' } }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def resolve_entity_id(session_id, name, scope_ids: nil)
|
|
211
|
+
return nil if name.nil? || name.to_s.empty?
|
|
212
|
+
|
|
213
|
+
match = entities(session_id: session_id, scope_ids: scope_ids).find do |e|
|
|
214
|
+
e[:canonical].to_s.downcase == name.to_s.downcase ||
|
|
215
|
+
e[:name].to_s.downcase == name.to_s.downcase
|
|
216
|
+
end
|
|
217
|
+
match && match[:id]
|
|
50
218
|
end
|
|
51
219
|
|
|
52
220
|
private
|
|
53
221
|
|
|
54
|
-
attr_reader :
|
|
222
|
+
attr_reader :by_scope, :entities_index, :summaries, :events, :edges
|
|
55
223
|
|
|
56
|
-
def active_item(
|
|
57
|
-
|
|
224
|
+
def active_item(scope_id:, type:, key:)
|
|
225
|
+
by_scope[scope_id].find { |row| row[:type] == type && row[:key] == key && row[:status] == 'active' }
|
|
58
226
|
end
|
|
59
227
|
|
|
60
|
-
def
|
|
228
|
+
def included_lifecycle?(item)
|
|
229
|
+
Governance::Tiers::CONTEXT_LIFECYCLE.include?(item[:lifecycle_status] || 'raw')
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def update_entities(scope_id:, record:)
|
|
61
233
|
return unless record[:type] == 'entities'
|
|
62
234
|
|
|
63
235
|
value = record[:value_json]
|
|
64
236
|
canonical = value[:canonical] || value[:name]
|
|
65
|
-
existing = entities_index[
|
|
237
|
+
existing = entities_index[scope_id].find { |e| e[:canonical] == canonical && e[:kind] == value[:kind] }
|
|
66
238
|
return if existing
|
|
67
239
|
|
|
68
|
-
entities_index[
|
|
240
|
+
entities_index[scope_id] << {
|
|
69
241
|
id: SecureRandom.uuid,
|
|
70
242
|
name: value[:name] || canonical,
|
|
71
243
|
kind: value[:kind] || 'other',
|
|
72
244
|
canonical: canonical,
|
|
73
|
-
memory_item_id: record[:id]
|
|
245
|
+
memory_item_id: record[:id],
|
|
246
|
+
scope_id: record[:scope_id],
|
|
247
|
+
scope: record[:scope],
|
|
248
|
+
source_session_id: record[:source_session_id]
|
|
74
249
|
}
|
|
75
250
|
end
|
|
251
|
+
|
|
252
|
+
def normalize_scope_ids(session_id, scope_ids)
|
|
253
|
+
ids = Array(scope_ids).compact
|
|
254
|
+
ids = [legacy_scope_id(session_id)] if ids.empty? && session_id
|
|
255
|
+
ids
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def legacy_scope_id(session_id)
|
|
259
|
+
"legacy:session:#{session_id}"
|
|
260
|
+
end
|
|
76
261
|
end
|
|
77
262
|
end
|
|
78
263
|
end
|