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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/MEMPAL_GUIDE.md +1074 -0
  4. data/README.en.md +2 -2
  5. data/README.md +356 -62
  6. data/config/brain.yml +69 -1
  7. data/conversation_demo.rb +5 -5
  8. data/db/migrate/002_turn_events_payload.sql +9 -0
  9. data/db/migrate/003_tiers_and_lifecycle.sql +28 -0
  10. data/db/migrate/004_kg_edges.sql +30 -0
  11. data/db/migrate/005_domains_and_memory_scopes.sql +163 -0
  12. data/docs/coding_todo.md +139 -0
  13. data/docs/context_package.md +220 -0
  14. data/docs/evidence_pack.md +190 -0
  15. data/docs/gap_vs_mempal.md +161 -0
  16. data/docs/mcp.md +93 -0
  17. data/docs/memory_types.md +278 -0
  18. data/docs/multi_scope_memory_refactor_plan.md +483 -0
  19. data/docs/multi_scope_migration.md +65 -0
  20. data/docs/policies.md +308 -0
  21. data/docs/retrieval_plan.md +231 -0
  22. data/docs/smartbrain_design.md +299 -0
  23. data/docs/user_guide.md +546 -0
  24. data/example.rb +2 -2
  25. data/examples/01_memory_basic.rb +57 -0
  26. data/examples/02_governance.rb +63 -0
  27. data/examples/03_postgres_persistence.rb +63 -0
  28. data/examples/04_ollama_llm.rb +69 -0
  29. data/examples/05_smart_rag_integration.rb +79 -0
  30. data/examples/06_multi_scope_memory.rb +50 -0
  31. data/examples/README.md +49 -0
  32. data/exe/smart_brain +168 -0
  33. data/lib/smart_brain/adapters/smart_rag/direct_client.rb +16 -5
  34. data/lib/smart_brain/adapters/smart_rag/http_client.rb +16 -5
  35. data/lib/smart_brain/adapters/smart_rag/null_client.rb +7 -2
  36. data/lib/smart_brain/adapters/smart_rag/scope_filter.rb +60 -0
  37. data/lib/smart_brain/configuration.rb +57 -0
  38. data/lib/smart_brain/consolidator/working_summary.rb +80 -12
  39. data/lib/smart_brain/context_composer/composer.rb +40 -3
  40. data/lib/smart_brain/contracts/retrieval_plan.rb +10 -0
  41. data/lib/smart_brain/contracts/scope_context.rb +46 -0
  42. data/lib/smart_brain/contracts/scope_ref.rb +25 -0
  43. data/lib/smart_brain/db.rb +109 -0
  44. data/lib/smart_brain/event_store/in_memory.rb +6 -2
  45. data/lib/smart_brain/event_store/postgres.rb +199 -0
  46. data/lib/smart_brain/fusion/merger.rb +31 -2
  47. data/lib/smart_brain/governance/briefing.rb +146 -0
  48. data/lib/smart_brain/governance/fact_check.rb +110 -0
  49. data/lib/smart_brain/governance/knowledge_graph.rb +60 -0
  50. data/lib/smart_brain/governance/lifecycle.rb +225 -0
  51. data/lib/smart_brain/governance/tiers.rb +60 -0
  52. data/lib/smart_brain/memory_extractor/extractor.rb +25 -7
  53. data/lib/smart_brain/memory_store/in_memory.rb +202 -17
  54. data/lib/smart_brain/memory_store/postgres.rb +500 -0
  55. data/lib/smart_brain/model_provider/base.rb +87 -0
  56. data/lib/smart_brain/model_provider/factory.rb +49 -0
  57. data/lib/smart_brain/model_provider/ollama.rb +60 -0
  58. data/lib/smart_brain/model_provider/openai.rb +60 -0
  59. data/lib/smart_brain/model_provider/stub.rb +26 -0
  60. data/lib/smart_brain/model_provider.rb +7 -0
  61. data/lib/smart_brain/observability/tracker.rb +39 -1
  62. data/lib/smart_brain/retrievers/exact_retriever.rb +6 -0
  63. data/lib/smart_brain/retrievers/memory_retriever.rb +59 -5
  64. data/lib/smart_brain/runtime.rb +288 -16
  65. data/lib/smart_brain/scopes/conflict_resolver.rb +67 -0
  66. data/lib/smart_brain/scopes/registry.rb +133 -0
  67. data/lib/smart_brain/scopes/resolver.rb +32 -0
  68. data/lib/smart_brain/server/http_app.rb +143 -0
  69. data/lib/smart_brain/server/mcp_server.rb +385 -0
  70. data/lib/smart_brain/server/service.rb +129 -0
  71. data/lib/smart_brain/support/levenshtein.rb +35 -0
  72. data/lib/smart_brain/version.rb +1 -1
  73. data/lib/smart_brain.rb +49 -4
  74. 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
- def initialize(config:)
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