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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'scope_ref'
|
|
4
|
+
|
|
5
|
+
module SmartBrain
|
|
6
|
+
module Contracts
|
|
7
|
+
class ScopeContext
|
|
8
|
+
LEGACY_DOMAIN_ID = 'legacy'
|
|
9
|
+
|
|
10
|
+
def self.normalize(domain_id:, session_id:, scope_context:, allowed_types: ScopeRef::DEFAULT_ALLOWED_TYPES)
|
|
11
|
+
normalized_domain = domain_id.to_s.strip
|
|
12
|
+
if normalized_domain.empty? && scope_context
|
|
13
|
+
raise ArgumentError, 'domain_id is required when scope_context is provided'
|
|
14
|
+
end
|
|
15
|
+
normalized_domain = LEGACY_DOMAIN_ID if normalized_domain.empty?
|
|
16
|
+
raise ArgumentError, 'session_id is required' if session_id.to_s.strip.empty?
|
|
17
|
+
|
|
18
|
+
raw = scope_context || legacy_context(session_id)
|
|
19
|
+
read = Array(raw[:read] || raw['read']).map { |ref| ScopeRef.normalize(ref, allowed_types: allowed_types) }
|
|
20
|
+
raise ArgumentError, 'scope_context.read must not be empty' if read.empty?
|
|
21
|
+
|
|
22
|
+
keys = read.map { |ref| "#{ref[:type]}:#{ref[:id]}" }
|
|
23
|
+
duplicates = keys.tally.select { |_key, count| count > 1 }.keys
|
|
24
|
+
raise ArgumentError, "duplicate scope refs: #{duplicates.join(', ')}" unless duplicates.empty?
|
|
25
|
+
|
|
26
|
+
default_write = ScopeRef.normalize(raw[:default_write] || raw['default_write'], allowed_types: allowed_types)
|
|
27
|
+
write_raw = raw.key?(:write) ? raw[:write] : raw['write']
|
|
28
|
+
write = Array(write_raw || [default_write]).map { |ref| ScopeRef.normalize(ref, allowed_types: allowed_types) }
|
|
29
|
+
write_keys = write.map { |ref| "#{ref[:type]}:#{ref[:id]}" }
|
|
30
|
+
write_duplicates = write_keys.tally.select { |_key, count| count > 1 }.keys
|
|
31
|
+
raise ArgumentError, "duplicate writable scope refs: #{write_duplicates.join(', ')}" unless write_duplicates.empty?
|
|
32
|
+
raise ArgumentError, 'scope_context.write must be included in read' unless write_keys.all? { |key| keys.include?(key) }
|
|
33
|
+
unless write_keys.include?("#{default_write[:type]}:#{default_write[:id]}")
|
|
34
|
+
raise ArgumentError, 'scope_context.default_write must be included in write'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
{ domain_id: normalized_domain, read: read, write: write, default_write: default_write }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.legacy_context(session_id)
|
|
41
|
+
ref = { type: 'session', id: session_id.to_s }
|
|
42
|
+
{ read: [ref], write: [ref], default_write: ref }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Contracts
|
|
5
|
+
class ScopeRef
|
|
6
|
+
DEFAULT_ALLOWED_TYPES = %w[global project expert task session].freeze
|
|
7
|
+
|
|
8
|
+
def self.normalize(value, allowed_types: DEFAULT_ALLOWED_TYPES)
|
|
9
|
+
raise ArgumentError, 'scope ref must be an object' unless value.respond_to?(:[])
|
|
10
|
+
|
|
11
|
+
type = (value[:type] || value['type']).to_s
|
|
12
|
+
external_id = (value[:id] || value['id']).to_s
|
|
13
|
+
raise ArgumentError, "invalid scope type: #{type}" unless Array(allowed_types).map(&:to_s).include?(type)
|
|
14
|
+
raise ArgumentError, 'scope id is required' if external_id.strip.empty?
|
|
15
|
+
|
|
16
|
+
{ type: type, id: external_id }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.key(value)
|
|
20
|
+
ref = normalize(value)
|
|
21
|
+
"#{ref[:type]}:#{ref[:id]}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sequel'
|
|
4
|
+
require 'json'
|
|
5
|
+
Sequel.extension :pg_json
|
|
6
|
+
|
|
7
|
+
module SmartBrain
|
|
8
|
+
# SmartBrain::DB owns the Sequel/Postgres connection and schema migration.
|
|
9
|
+
#
|
|
10
|
+
# Backed by config/brain.yml `storage.database` (overridable via
|
|
11
|
+
# SMARTBRAIN_DB_{HOST,PORT,NAME,USER,PASSWORD} env). Connections are memoized
|
|
12
|
+
# per connection signature so the runtime, server, and CLI share one pool.
|
|
13
|
+
module DB
|
|
14
|
+
SCHEMA_SQL_PATH = File.expand_path('../../db/migrate/001_init.sql', __dir__)
|
|
15
|
+
MIGRATIONS_DIR = File.expand_path('../../db/migrate', __dir__)
|
|
16
|
+
DEFAULT_ADAPTER = 'postgres'
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Returns a memoized Sequel::Database for the given config (or config hash).
|
|
20
|
+
def connect(config)
|
|
21
|
+
opts = connection_opts(config)
|
|
22
|
+
signature = opts.hash
|
|
23
|
+
return pools[signature] if pools.key?(signature)
|
|
24
|
+
|
|
25
|
+
db = Sequel.connect(opts)
|
|
26
|
+
db.sql_log_level = :error if db.respond_to?(:sql_log_level=)
|
|
27
|
+
db.extension(:pg_json) rescue nil
|
|
28
|
+
pools[signature] = db
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Run all bundled migration SQL files in order (each is idempotent:
|
|
32
|
+
# CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN IF NOT EXISTS).
|
|
33
|
+
def migrate(db)
|
|
34
|
+
Dir.glob(File.join(MIGRATIONS_DIR, '*.sql')).sort.each do |path|
|
|
35
|
+
db.run(File.read(path))
|
|
36
|
+
end
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# True if a Postgres connection can be established for the given config.
|
|
41
|
+
def reachable?(config)
|
|
42
|
+
db = Sequel.connect(connection_opts(config))
|
|
43
|
+
begin
|
|
44
|
+
db.test_connection
|
|
45
|
+
rescue StandardError
|
|
46
|
+
false
|
|
47
|
+
else
|
|
48
|
+
true
|
|
49
|
+
ensure
|
|
50
|
+
db.disconnect if db
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Drop all memoized connections (used by tests / reconfigure).
|
|
55
|
+
def disconnect!
|
|
56
|
+
pools.each_value(&:disconnect)
|
|
57
|
+
pools.clear
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Recursively symbolize a JSONB value read from Postgres. Robust whether
|
|
61
|
+
# the adapter returns a parsed Hash/Array, a Sequel JSONBHash/JSONBArray
|
|
62
|
+
# wrapper (NOT a Hash subclass in recent Sequel), or a raw JSON String.
|
|
63
|
+
def symbolize_jsonb(obj)
|
|
64
|
+
case obj
|
|
65
|
+
when String
|
|
66
|
+
begin
|
|
67
|
+
parsed = JSON.parse(obj)
|
|
68
|
+
parsed.is_a?(Hash) || parsed.is_a?(Array) ? symbolize_jsonb(parsed) : obj
|
|
69
|
+
rescue JSON::ParserError, TypeError
|
|
70
|
+
obj
|
|
71
|
+
end
|
|
72
|
+
when ->(o) { o.respond_to?(:to_hash) }
|
|
73
|
+
obj.to_hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize_jsonb(v) }
|
|
74
|
+
when ->(o) { !o.nil? && !o.is_a?(String) && o.respond_to?(:to_a) }
|
|
75
|
+
obj.to_a.map { |v| symbolize_jsonb(v) }
|
|
76
|
+
else
|
|
77
|
+
obj
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Stringify keys for storage into a JSONB column (Sequel pg_json stores
|
|
82
|
+
# string keys; symbol keys round-trip fine but strings are canonical).
|
|
83
|
+
def stringify_keys(obj)
|
|
84
|
+
return obj unless obj.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
obj.transform_keys(&:to_s)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def pools
|
|
92
|
+
@pools ||= {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def connection_opts(config)
|
|
96
|
+
cfg = config.respond_to?(:database_config) ? config.database_config : config.fetch(:database, {})
|
|
97
|
+
adapter = (cfg[:adapter] || DEFAULT_ADAPTER).to_sym
|
|
98
|
+
opts = { adapter: adapter }
|
|
99
|
+
opts[:host] = cfg[:host].to_s if cfg[:host]
|
|
100
|
+
opts[:port] = cfg[:port].to_i if cfg[:port]
|
|
101
|
+
opts[:database] = cfg[:database].to_s if cfg[:database]
|
|
102
|
+
opts[:user] = cfg[:user].to_s if cfg[:user]
|
|
103
|
+
opts[:password] = cfg[:password].to_s if cfg[:password]
|
|
104
|
+
opts[:max_connections] = (cfg[:max_connections] || 4).to_i
|
|
105
|
+
opts
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -9,8 +9,12 @@ module SmartBrain
|
|
|
9
9
|
@sessions = {}
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def append_turn(session_id:, turn_events:, created_at:)
|
|
13
|
-
session = sessions[session_id]
|
|
12
|
+
def append_turn(session_id:, turn_events:, created_at:, domain_id: 'legacy')
|
|
13
|
+
session = sessions[session_id]
|
|
14
|
+
if session && session[:domain_id] != domain_id
|
|
15
|
+
raise ArgumentError, "session #{session_id} belongs to domain #{session[:domain_id]}"
|
|
16
|
+
end
|
|
17
|
+
session ||= sessions[session_id] = { domain_id: domain_id, seq: 0, turns: [] }
|
|
14
18
|
session[:seq] += 1
|
|
15
19
|
turn_id = SecureRandom.uuid
|
|
16
20
|
messages = normalize_messages(turn_id: turn_id, messages: turn_events[:messages] || [], created_at: created_at)
|
|
@@ -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
|