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,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module SmartBrain
|
|
7
|
+
module EventStore
|
|
8
|
+
# Postgres-backed EventStore. Drop-in replacement for EventStore::InMemory:
|
|
9
|
+
# writes the truth layer (sessions/turns/messages/refs) and reproduces the
|
|
10
|
+
# same return shapes so the runtime and diagnostics are backend-agnostic.
|
|
11
|
+
class Postgres
|
|
12
|
+
def initialize(db:, clock:)
|
|
13
|
+
@db = db
|
|
14
|
+
@clock = clock
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def append_turn(session_id:, turn_events:, created_at:, domain_id: 'legacy')
|
|
18
|
+
db.transaction do
|
|
19
|
+
ensure_session(session_id, domain_id)
|
|
20
|
+
seq = next_seq(session_id)
|
|
21
|
+
turn_id = SecureRandom.uuid
|
|
22
|
+
messages = normalize_messages(turn_id: turn_id, messages: turn_events[:messages] || [], created_at: created_at)
|
|
23
|
+
refs = normalize_refs(turn_id: turn_id, refs: turn_events[:refs] || [], created_at: created_at)
|
|
24
|
+
normalized_events = turn_events.merge(messages: messages, refs: refs)
|
|
25
|
+
|
|
26
|
+
db[:turns].insert(
|
|
27
|
+
id: turn_id,
|
|
28
|
+
session_id: session_id,
|
|
29
|
+
seq: seq,
|
|
30
|
+
created_at: time_from(created_at),
|
|
31
|
+
events_json: Sequel.pg_jsonb(symbolizable(normalized_events))
|
|
32
|
+
)
|
|
33
|
+
insert_messages(turn_id, messages)
|
|
34
|
+
insert_refs(turn_id, refs)
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
id: turn_id,
|
|
38
|
+
session_id: session_id,
|
|
39
|
+
seq: seq,
|
|
40
|
+
created_at: iso8601(created_at),
|
|
41
|
+
turn_events: normalized_events
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def turns_count(session_id:)
|
|
47
|
+
db[:turns].where(session_id: session_id).count
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def recent_turns(session_id:, limit:)
|
|
51
|
+
last_turn_ids = db[:turns].where(session_id: session_id).order(Sequel.desc(:seq)).limit(limit).select(:id)
|
|
52
|
+
db[:messages].where(turn_id: last_turn_ids).order(:created_at).map do |m|
|
|
53
|
+
{
|
|
54
|
+
turn_id: m[:turn_id],
|
|
55
|
+
message_id: m[:id],
|
|
56
|
+
role: m[:role],
|
|
57
|
+
content: m[:content],
|
|
58
|
+
created_at: iso8601(m[:created_at])
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def recent_refs(session_id:, limit:)
|
|
64
|
+
last_turn_ids = db[:turns].where(session_id: session_id).order(Sequel.desc(:seq)).limit(limit).select(:id)
|
|
65
|
+
db[:refs].where(turn_id: last_turn_ids).order(:created_at).map do |r|
|
|
66
|
+
{
|
|
67
|
+
id: r[:id],
|
|
68
|
+
turn_id: r[:turn_id],
|
|
69
|
+
ref_type: r[:ref_type],
|
|
70
|
+
ref_uri: r[:ref_uri],
|
|
71
|
+
ref_meta_json: symbolize(r[:ref_meta_json]),
|
|
72
|
+
created_at: iso8601(r[:created_at])
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def entity_frequencies(session_id:, window_turns:)
|
|
78
|
+
payloads = db[:turns].where(session_id: session_id)
|
|
79
|
+
.order(Sequel.desc(:seq))
|
|
80
|
+
.limit(window_turns)
|
|
81
|
+
.select(:events_json)
|
|
82
|
+
.all
|
|
83
|
+
freq = Hash.new(0)
|
|
84
|
+
payloads.each do |row|
|
|
85
|
+
events = symbolize(row[:events_json])
|
|
86
|
+
Array(events[:entities]).each do |entity|
|
|
87
|
+
canonical = entity[:canonical] || entity[:name]
|
|
88
|
+
freq[canonical.to_s.downcase] += 1
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
freq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def all_turns(session_id:)
|
|
95
|
+
dataset = session_id.nil? ? db[:turns] : db[:turns].where(session_id: session_id)
|
|
96
|
+
dataset.order(:session_id, :seq).map do |t|
|
|
97
|
+
{
|
|
98
|
+
id: t[:id],
|
|
99
|
+
session_id: t[:session_id],
|
|
100
|
+
seq: t[:seq],
|
|
101
|
+
created_at: iso8601(t[:created_at]),
|
|
102
|
+
turn_events: symbolize(t[:events_json])
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
attr_reader :db, :clock
|
|
110
|
+
|
|
111
|
+
def ensure_session(session_id, domain_id)
|
|
112
|
+
row = db[:sessions].where(id: session_id).first
|
|
113
|
+
if row && row[:domain_id] != domain_id
|
|
114
|
+
raise ArgumentError, "session #{session_id} belongs to domain #{row[:domain_id]}"
|
|
115
|
+
end
|
|
116
|
+
return if row
|
|
117
|
+
|
|
118
|
+
db[:sessions].insert(id: session_id, domain_id: domain_id, created_at: time_from(clock.call), metadata_json: Sequel.pg_jsonb({}))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def next_seq(session_id)
|
|
122
|
+
(db[:turns].where(session_id: session_id).max(:seq) || 0) + 1
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def insert_messages(turn_id, messages)
|
|
126
|
+
messages.each do |m|
|
|
127
|
+
db[:messages].insert(
|
|
128
|
+
id: m[:id],
|
|
129
|
+
turn_id: turn_id,
|
|
130
|
+
role: m[:role],
|
|
131
|
+
content: m[:content].to_s,
|
|
132
|
+
model: m[:model],
|
|
133
|
+
created_at: time_from(m[:created_at]),
|
|
134
|
+
meta_json: Sequel.pg_jsonb({})
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def insert_refs(turn_id, refs)
|
|
140
|
+
refs.each do |r|
|
|
141
|
+
db[:refs].insert(
|
|
142
|
+
id: r[:id],
|
|
143
|
+
turn_id: turn_id,
|
|
144
|
+
ref_type: r[:ref_type].to_s,
|
|
145
|
+
ref_uri: r[:ref_uri].to_s,
|
|
146
|
+
ref_meta_json: Sequel.pg_jsonb(symbolizable(r[:ref_meta_json] || {})),
|
|
147
|
+
created_at: time_from(r[:created_at])
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def normalize_messages(turn_id:, messages:, created_at:)
|
|
153
|
+
messages.map do |m|
|
|
154
|
+
m.merge(
|
|
155
|
+
id: m[:id] || SecureRandom.uuid,
|
|
156
|
+
turn_id: turn_id,
|
|
157
|
+
created_at: m[:created_at] || iso8601(created_at)
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_refs(turn_id:, refs:, created_at:)
|
|
163
|
+
refs.map do |r|
|
|
164
|
+
r.merge(
|
|
165
|
+
id: r[:id] || SecureRandom.uuid,
|
|
166
|
+
turn_id: turn_id,
|
|
167
|
+
created_at: r[:created_at] || iso8601(created_at),
|
|
168
|
+
ref_meta_json: r[:ref_meta_json] || {}
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def time_from(value)
|
|
174
|
+
case value
|
|
175
|
+
when Time then value
|
|
176
|
+
when String then Time.parse(value)
|
|
177
|
+
else clock.call
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def iso8601(value)
|
|
182
|
+
time = case value
|
|
183
|
+
when Time then value
|
|
184
|
+
when String then Time.parse(value)
|
|
185
|
+
else clock.call
|
|
186
|
+
end
|
|
187
|
+
time.iso8601
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def symbolizable(obj)
|
|
191
|
+
SmartBrain::DB.stringify_keys(obj)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def symbolize(obj)
|
|
195
|
+
SmartBrain::DB.symbolize_jsonb(obj)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
module SmartBrain
|
|
4
4
|
module Fusion
|
|
5
5
|
class Merger
|
|
6
|
-
|
|
6
|
+
RERANK_THRESHOLD = 4
|
|
7
|
+
RERANK_MAX = 20
|
|
8
|
+
|
|
9
|
+
def initialize(config:, model_provider: nil)
|
|
7
10
|
@config = config
|
|
11
|
+
@model_provider = model_provider
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def merge(query:, memory_evidence:, resource_evidence:)
|
|
11
15
|
combined = (memory_evidence + normalize_resource(resource_evidence))
|
|
12
16
|
deduped = dedupe(combined)
|
|
13
17
|
ranked = deduped.sort_by { |item| -rerank_score(item, query) }
|
|
18
|
+
ranked = llm_rerank(query, ranked) if llm_rerank_enabled? && ranked.size >= RERANK_THRESHOLD
|
|
14
19
|
diversified, dropped = apply_diversity(ranked)
|
|
15
20
|
selected = apply_budget(diversified)
|
|
16
21
|
|
|
@@ -23,7 +28,31 @@ module SmartBrain
|
|
|
23
28
|
|
|
24
29
|
private
|
|
25
30
|
|
|
26
|
-
attr_reader :config
|
|
31
|
+
attr_reader :config, :model_provider
|
|
32
|
+
|
|
33
|
+
def llm_rerank_enabled?
|
|
34
|
+
return false unless model_provider&.llm?
|
|
35
|
+
return false unless config.llm.fetch(:rerank_enabled, false)
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Re-rank the candidate head with the LLM; preserve lexical order for the
|
|
41
|
+
# tail and on any error (graceful fallback so compose never breaks).
|
|
42
|
+
def llm_rerank(query, items)
|
|
43
|
+
head = items.first(RERANK_MAX)
|
|
44
|
+
docs = head.map { |i| "#{i[:title]}: #{i[:snippet]}" }
|
|
45
|
+
scored = model_provider.rerank(query: query, documents: docs)
|
|
46
|
+
scores_by_index = scored.each_with_object({}) { |s, h| h[s[:index]] = s[:score].to_f }
|
|
47
|
+
|
|
48
|
+
reordered = head.each_with_index.map do |item, idx|
|
|
49
|
+
item.merge(_llm: scores_by_index.fetch(idx, 0.0))
|
|
50
|
+
end.sort_by { |i| -i[:_llm] }
|
|
51
|
+
reordered.each { |i| i.delete(:_llm) }
|
|
52
|
+
reordered + items[RERANK_MAX..]
|
|
53
|
+
rescue StandardError
|
|
54
|
+
items
|
|
55
|
+
end
|
|
27
56
|
|
|
28
57
|
def normalize_resource(items)
|
|
29
58
|
items.map do |item|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tiers'
|
|
4
|
+
|
|
5
|
+
module SmartBrain
|
|
6
|
+
module Governance
|
|
7
|
+
# Deterministic, citation-first briefing (mempal parity). No LLM, no DB
|
|
8
|
+
# writes — assembles purely from memory_store/event_store. Two modes:
|
|
9
|
+
# - brief: full cognitive snapshot (summary/key_facts/evidence/entities/
|
|
10
|
+
# unresolved/uncertainty/next_actions), every fact carries a citation.
|
|
11
|
+
# - wake_up: minimal L0/L1 importance-ordered resume payload.
|
|
12
|
+
class Briefing
|
|
13
|
+
KEY_FACT_TYPES = %w[decisions goals patterns].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(memory_store:, event_store:, config:)
|
|
16
|
+
@memory_store = memory_store
|
|
17
|
+
@event_store = event_store
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def brief(session_id:, query: nil, scope_ids: nil, domain_id: 'legacy', scope_context: nil)
|
|
22
|
+
items = memory_store.active_items(session_id: session_id, scope_ids: scope_ids)
|
|
23
|
+
recent = event_store.recent_turns(session_id: session_id, limit: briefing_recent_turns)
|
|
24
|
+
search_query = query || last_user_message(recent) || ''
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
session_id: session_id,
|
|
28
|
+
domain_id: domain_id,
|
|
29
|
+
scope_context: scope_context,
|
|
30
|
+
summary: summary_text(session_id),
|
|
31
|
+
key_facts: key_facts(items),
|
|
32
|
+
evidence: evidence(session_id: session_id, scope_ids: scope_ids, query: search_query),
|
|
33
|
+
entities: memory_store.entities(session_id: session_id, scope_ids: scope_ids),
|
|
34
|
+
unresolved: unresolved(items),
|
|
35
|
+
uncertainty: uncertainty(items),
|
|
36
|
+
next_actions: next_actions(items),
|
|
37
|
+
generated_at: Time.now.utc.iso8601
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def wake_up(session_id:, scope_ids: nil, domain_id: 'legacy', scope_context: nil)
|
|
42
|
+
items = memory_store.active_items(session_id: session_id, scope_ids: scope_ids)
|
|
43
|
+
summary = memory_store.latest_summary(session_id: session_id)
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
session_id: session_id,
|
|
47
|
+
domain_id: domain_id,
|
|
48
|
+
scope_context: scope_context,
|
|
49
|
+
l0: {
|
|
50
|
+
identity: items.select { |i| i[:type] == 'profile' },
|
|
51
|
+
principles: items.select { |i| i[:tier] == 'dao_tian' },
|
|
52
|
+
preferences: items.select { |i| i[:type] == 'preferences' }
|
|
53
|
+
},
|
|
54
|
+
l1: {
|
|
55
|
+
goals_decisions: items.select { |i| %w[goals decisions].include?(i[:type]) },
|
|
56
|
+
summary: summary ? summary[:text] : nil
|
|
57
|
+
},
|
|
58
|
+
generated_at: Time.now.utc.iso8601
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resume(query:)
|
|
63
|
+
matches = event_store.all_turns(session_id: nil)
|
|
64
|
+
.map { |t| t[:session_id] }.uniq
|
|
65
|
+
.select { |sid| sid.to_s.downcase.include?(query.to_s.downcase) }
|
|
66
|
+
matches.map do |sid|
|
|
67
|
+
items = memory_store.active_items(session_id: sid)
|
|
68
|
+
{
|
|
69
|
+
session_id: sid,
|
|
70
|
+
recent_decisions: items.select { |i| i[:type] == 'decisions' }.first(5).map { |i| fact_citation(i) },
|
|
71
|
+
next_actions: next_actions(items)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
attr_reader :memory_store, :event_store, :config
|
|
79
|
+
|
|
80
|
+
def summary_text(session_id)
|
|
81
|
+
summary = memory_store.latest_summary(session_id: session_id)
|
|
82
|
+
summary ? summary[:text] : nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def key_facts(items)
|
|
86
|
+
selected = items.select { |i| KEY_FACT_TYPES.include?(i[:type]) }
|
|
87
|
+
Tiers.sort_by_tier(selected).first(briefing_key_facts_max).map { |i| fact_citation(i) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fact_citation(item)
|
|
91
|
+
{
|
|
92
|
+
memory_item_id: item[:id],
|
|
93
|
+
type: item[:type],
|
|
94
|
+
key: item[:key],
|
|
95
|
+
tier: item[:tier] || Tiers.tier_for(item[:type]),
|
|
96
|
+
confidence: item[:confidence],
|
|
97
|
+
source_turn_id: item[:source_turn_id],
|
|
98
|
+
status: item[:status],
|
|
99
|
+
lifecycle_status: item[:lifecycle_status]
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def evidence(session_id:, scope_ids:, query:)
|
|
104
|
+
return [] if query.to_s.strip.empty?
|
|
105
|
+
|
|
106
|
+
memory_store.search_memory(query: query, session_id: session_id, scope_ids: scope_ids, limit: briefing_key_facts_max)
|
|
107
|
+
rescue StandardError
|
|
108
|
+
[]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def unresolved(items)
|
|
112
|
+
items.select { |i| i[:type] == 'tasks' && %w[todo doing blocked].include?(task_status(i)) }
|
|
113
|
+
.map { |i| fact_citation(i) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def uncertainty(items)
|
|
117
|
+
items.select { |i| (i[:confidence] || 1.0) < uncertainty_threshold }.map { |i| fact_citation(i) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def next_actions(items)
|
|
121
|
+
items.select { |i| i[:type] == 'tasks' && %w[todo doing].include?(task_status(i)) }
|
|
122
|
+
.map { |i| fact_citation(i) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def task_status(item)
|
|
126
|
+
item.dig(:value_json, :status).to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def last_user_message(recent)
|
|
130
|
+
recent.reverse.find { |t| t[:role] == 'user' } && recent.reverse.find { |t| t[:role] == 'user' }[:content]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def briefing_key_facts_max
|
|
134
|
+
config.briefing.fetch(:key_facts_max, 8) rescue 8
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def uncertainty_threshold
|
|
138
|
+
config.briefing.fetch(:uncertainty_threshold, 0.6) rescue 0.6
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def briefing_recent_turns
|
|
142
|
+
config.briefing.fetch(:recent_turns, 6) rescue 6
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative '../support/levenshtein'
|
|
5
|
+
|
|
6
|
+
module SmartBrain
|
|
7
|
+
module Governance
|
|
8
|
+
# Offline, zero-LLM, zero-network contradiction detector (mempal parity).
|
|
9
|
+
# Scans a piece of text against a session's entities + KG edges and reports
|
|
10
|
+
# three finding types:
|
|
11
|
+
# - SimilarNameConflict: a token within Levenshtein <= N of a known entity
|
|
12
|
+
# - RelationContradiction: text mentions both endpoints of an active edge
|
|
13
|
+
# alongside a negation/contrary cue (may contradict the relation)
|
|
14
|
+
# - StaleFact: text references an edge whose valid_to < now (invalidated)
|
|
15
|
+
#
|
|
16
|
+
# Heuristic by design — surfaces candidates for human/agent review.
|
|
17
|
+
class FactCheck
|
|
18
|
+
TOKEN_RE = /[[:alnum:]_\-\p{Han}]+/.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(memory_store:, config:)
|
|
21
|
+
@memory_store = memory_store
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def check(session_id:, text:, now: Time.now.utc, scope_ids: nil)
|
|
26
|
+
now = Time.parse(now).utc if now.is_a?(String)
|
|
27
|
+
text_l = text.to_s.downcase
|
|
28
|
+
tokens = text.to_s.downcase.scan(TOKEN_RE)
|
|
29
|
+
|
|
30
|
+
findings = []
|
|
31
|
+
findings.concat(similar_name_conflicts(session_id:, scope_ids:, tokens: tokens))
|
|
32
|
+
findings.concat(relation_contradictions(session_id:, scope_ids:, text_l: text_l))
|
|
33
|
+
findings.concat(stale_facts(session_id:, scope_ids:, text_l: text_l, now: now))
|
|
34
|
+
|
|
35
|
+
{ findings: findings, checked_at: Time.now.utc.iso8601,
|
|
36
|
+
counts: { total: findings.size,
|
|
37
|
+
similar_name: findings.count { |f| f[:type] == 'SimilarNameConflict' },
|
|
38
|
+
relation_contradiction: findings.count { |f| f[:type] == 'RelationContradiction' },
|
|
39
|
+
stale_fact: findings.count { |f| f[:type] == 'StaleFact' } } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :memory_store, :config
|
|
45
|
+
|
|
46
|
+
def similar_name_conflicts(session_id:, scope_ids:, tokens:)
|
|
47
|
+
distance = config.fact_check.fetch(:similar_name_distance, 2) rescue 2
|
|
48
|
+
names = memory_store.entities(session_id: session_id, scope_ids: scope_ids).each_with_object([]) do |e, acc|
|
|
49
|
+
acc << { name: e[:canonical].to_s, entity: e } unless e[:canonical].to_s.empty?
|
|
50
|
+
acc << { name: e[:name].to_s, entity: e } unless e[:name].to_s.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
tokens.product(names).each_with_object([]) do |(token, named), acc|
|
|
54
|
+
next if token.length < 3 || named[:name].length < 3
|
|
55
|
+
next if token == named[:name]
|
|
56
|
+
|
|
57
|
+
d = Support::Levenshtein.distance(token, named[:name])
|
|
58
|
+
next unless d.between?(1, distance)
|
|
59
|
+
|
|
60
|
+
acc << { type: 'SimilarNameConflict', severity: 'warning',
|
|
61
|
+
detail: "'#{token}' is within Levenshtein #{d} of entity '#{named[:name]}'",
|
|
62
|
+
refs: { token: token, entity: named[:name], entity_id: named[:entity][:id], distance: d } }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def relation_contradictions(session_id:, scope_ids:, text_l:)
|
|
67
|
+
cues = negation_cues
|
|
68
|
+
edges = safe_edges(session_id, scope_ids).select { |e| e[:status] == 'active' }
|
|
69
|
+
return [] if edges.empty? || cues.empty?
|
|
70
|
+
|
|
71
|
+
edges.each_with_object([]) do |edge, acc|
|
|
72
|
+
s, o = edge[:subject].downcase, edge[:object].downcase
|
|
73
|
+
next unless text_l.include?(s) && text_l.include?(o)
|
|
74
|
+
next unless cues.any? { |cue| text_l.include?(cue.to_s.downcase) }
|
|
75
|
+
|
|
76
|
+
acc << { type: 'RelationContradiction', severity: 'warning',
|
|
77
|
+
detail: "text may contradict relation '#{edge[:subject]} #{edge[:predicate]} #{edge[:object]}'",
|
|
78
|
+
refs: { edge_id: edge[:id], subject: edge[:subject], predicate: edge[:predicate], object: edge[:object] } }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stale_facts(session_id:, scope_ids:, text_l:, now:)
|
|
83
|
+
safe_edges(session_id, scope_ids).select { |e| e[:status] == 'invalidated' && e[:valid_to] }
|
|
84
|
+
.each_with_object([]) do |edge, acc|
|
|
85
|
+
valid_to = Time.parse(edge[:valid_to].to_s).utc rescue next
|
|
86
|
+
next if valid_to >= now
|
|
87
|
+
|
|
88
|
+
s, o = edge[:subject].downcase, edge[:object].downcase
|
|
89
|
+
next unless text_l.include?(s) || text_l.include?(o)
|
|
90
|
+
|
|
91
|
+
acc << { type: 'StaleFact', severity: 'info',
|
|
92
|
+
detail: "text references invalidated fact '#{edge[:subject]} #{edge[:predicate]} #{edge[:object]}' (valid_to #{edge[:valid_to]})",
|
|
93
|
+
refs: { edge_id: edge[:id], subject: edge[:subject], object: edge[:object], valid_to: edge[:valid_to] } }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def safe_edges(session_id, scope_ids)
|
|
98
|
+
return [] unless memory_store.respond_to?(:query_edges)
|
|
99
|
+
|
|
100
|
+
memory_store.query_edges(session_id: session_id, scope_ids: scope_ids, include_invalid: true)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def negation_cues
|
|
106
|
+
config.fact_check.fetch(:negation_cues, []) rescue []
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Governance
|
|
5
|
+
# Knowledge graph: typed triples (subject-predicate-object) with temporal
|
|
6
|
+
# validity (valid_from/valid_to) over a MemoryStore. Orthogonal to the
|
|
7
|
+
# Stage-1 lifecycle and to memory_items — edges live in kg_edges and are
|
|
8
|
+
# added explicitly (via turn_events[:edges] extraction) or by the agent.
|
|
9
|
+
class KnowledgeGraph
|
|
10
|
+
def initialize(memory_store:, config:)
|
|
11
|
+
@memory_store = memory_store
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add(session_id:, subject:, predicate:, object:, source_turn_id: nil,
|
|
16
|
+
source_memory_item_id: nil, confidence: nil, valid_from: nil,
|
|
17
|
+
scope_id: nil, scope: nil, source_session_id: nil)
|
|
18
|
+
memory_store.add_edge(
|
|
19
|
+
session_id: session_id,
|
|
20
|
+
scope_id: scope_id,
|
|
21
|
+
scope: scope,
|
|
22
|
+
source_session_id: source_session_id,
|
|
23
|
+
edge: {
|
|
24
|
+
subject: subject, predicate: predicate, object: object,
|
|
25
|
+
source_turn_id: source_turn_id, source_memory_item_id: source_memory_item_id,
|
|
26
|
+
confidence: confidence || default_confidence, valid_from: valid_from
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def query(session_id: nil, scope_ids: nil, subject: nil, predicate: nil, object: nil, include_invalid: false)
|
|
32
|
+
memory_store.query_edges(session_id: session_id, scope_ids: scope_ids, subject: subject,
|
|
33
|
+
predicate: predicate, object: object,
|
|
34
|
+
include_invalid: include_invalid)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def timeline(session_id: nil, scope_ids: nil, subject:)
|
|
38
|
+
memory_store.edges_for_subject(session_id: session_id, scope_ids: scope_ids, subject: subject)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def invalidate(edge_id:, reason: nil)
|
|
42
|
+
memory_store.invalidate_edge(id: edge_id, reason: reason)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stats(session_id: nil, scope_ids: nil)
|
|
46
|
+
memory_store.edge_stats(session_id: session_id, scope_ids: scope_ids)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :memory_store, :config
|
|
52
|
+
|
|
53
|
+
def default_confidence
|
|
54
|
+
config.kg.fetch(:default_confidence, 0.6)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
0.6
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|