smart_brain 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/MEMPAL_GUIDE.md +1074 -0
- data/README.en.md +173 -173
- data/README.md +467 -173
- data/config/brain.yml +69 -1
- data/conversation_demo.rb +438 -438
- data/db/migrate/002_turn_events_payload.sql +9 -0
- data/db/migrate/003_tiers_and_lifecycle.sql +28 -0
- data/db/migrate/004_kg_edges.sql +30 -0
- data/db/migrate/005_domains_and_memory_scopes.sql +163 -0
- data/docs/coding_todo.md +139 -0
- data/docs/context_package.md +220 -0
- data/docs/evidence_pack.md +190 -0
- data/docs/gap_vs_mempal.md +161 -0
- data/docs/mcp.md +93 -0
- data/docs/memory_types.md +278 -0
- data/docs/multi_scope_memory_refactor_plan.md +483 -0
- data/docs/multi_scope_migration.md +65 -0
- data/docs/policies.md +308 -0
- data/docs/retrieval_plan.md +231 -0
- data/docs/smartbrain_design.md +299 -0
- data/docs/user_guide.md +546 -0
- data/example.rb +91 -91
- data/examples/01_memory_basic.rb +57 -0
- data/examples/02_governance.rb +63 -0
- data/examples/03_postgres_persistence.rb +63 -0
- data/examples/04_ollama_llm.rb +69 -0
- data/examples/05_smart_rag_integration.rb +79 -0
- data/examples/06_multi_scope_memory.rb +50 -0
- data/examples/README.md +49 -0
- data/exe/smart_brain +168 -0
- data/lib/smart_brain/adapters/smart_rag/direct_client.rb +16 -5
- data/lib/smart_brain/adapters/smart_rag/http_client.rb +16 -5
- data/lib/smart_brain/adapters/smart_rag/null_client.rb +7 -2
- data/lib/smart_brain/adapters/smart_rag/scope_filter.rb +60 -0
- data/lib/smart_brain/configuration.rb +57 -0
- data/lib/smart_brain/consolidator/working_summary.rb +80 -12
- data/lib/smart_brain/context_composer/composer.rb +40 -3
- data/lib/smart_brain/contracts/retrieval_plan.rb +10 -0
- data/lib/smart_brain/contracts/scope_context.rb +46 -0
- data/lib/smart_brain/contracts/scope_ref.rb +25 -0
- data/lib/smart_brain/db.rb +109 -0
- data/lib/smart_brain/event_store/in_memory.rb +6 -2
- data/lib/smart_brain/event_store/postgres.rb +199 -0
- data/lib/smart_brain/fusion/merger.rb +31 -2
- data/lib/smart_brain/governance/briefing.rb +146 -0
- data/lib/smart_brain/governance/fact_check.rb +110 -0
- data/lib/smart_brain/governance/knowledge_graph.rb +60 -0
- data/lib/smart_brain/governance/lifecycle.rb +225 -0
- data/lib/smart_brain/governance/tiers.rb +60 -0
- data/lib/smart_brain/memory_extractor/extractor.rb +25 -7
- data/lib/smart_brain/memory_store/in_memory.rb +202 -17
- data/lib/smart_brain/memory_store/postgres.rb +500 -0
- data/lib/smart_brain/model_provider/base.rb +87 -0
- data/lib/smart_brain/model_provider/factory.rb +49 -0
- data/lib/smart_brain/model_provider/ollama.rb +60 -0
- data/lib/smart_brain/model_provider/openai.rb +60 -0
- data/lib/smart_brain/model_provider/stub.rb +26 -0
- data/lib/smart_brain/model_provider.rb +7 -0
- data/lib/smart_brain/observability/tracker.rb +39 -1
- data/lib/smart_brain/retrievers/exact_retriever.rb +6 -0
- data/lib/smart_brain/retrievers/memory_retriever.rb +59 -5
- data/lib/smart_brain/runtime.rb +288 -16
- data/lib/smart_brain/scopes/conflict_resolver.rb +67 -0
- data/lib/smart_brain/scopes/registry.rb +133 -0
- data/lib/smart_brain/scopes/resolver.rb +32 -0
- data/lib/smart_brain/server/http_app.rb +143 -0
- data/lib/smart_brain/server/mcp_server.rb +385 -0
- data/lib/smart_brain/server/service.rb +129 -0
- data/lib/smart_brain/support/levenshtein.rb +35 -0
- data/lib/smart_brain/version.rb +5 -5
- data/lib/smart_brain.rb +80 -35
- metadata +88 -36
data/exe/smart_brain
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SmartBrain CLI — drive the memory runtime from the shell or as an agent tool.
|
|
5
|
+
#
|
|
6
|
+
# smart_brain status
|
|
7
|
+
# smart_brain migrate (postgres backend only)
|
|
8
|
+
# smart_brain commit --data '{"session_id":..,"turn_events":{..}}'
|
|
9
|
+
# smart_brain compose --data '{"session_id":..,"user_message":..}'
|
|
10
|
+
# smart_brain search --data '{"session_id":..,"query":..}'
|
|
11
|
+
# smart_brain serve [--host 0.0.0.0] [--port 9292] [--config PATH]
|
|
12
|
+
# smart_brain mcp [--config PATH] (stdio JSON-RPC, for Claude Code / Cursor)
|
|
13
|
+
|
|
14
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
15
|
+
|
|
16
|
+
require 'optparse'
|
|
17
|
+
require 'json'
|
|
18
|
+
require 'smart_brain'
|
|
19
|
+
require 'smart_brain/server/service'
|
|
20
|
+
require 'smart_brain/server/http_app'
|
|
21
|
+
require 'smart_brain/server/mcp_server'
|
|
22
|
+
|
|
23
|
+
module SmartBrain
|
|
24
|
+
module CLI
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def run(argv)
|
|
28
|
+
config_path = ENV['SMARTBRAIN_CONFIG']
|
|
29
|
+
global = OptionParser.new do |opts|
|
|
30
|
+
opts.banner = 'Usage: smart_brain <command> [options]'
|
|
31
|
+
opts.on('--config PATH', 'Path to brain.yml') { |p| config_path = p }
|
|
32
|
+
opts.on('--version', 'Print version and exit') { puts SmartBrain::VERSION; exit 0 }
|
|
33
|
+
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
|
|
34
|
+
end
|
|
35
|
+
global.order!(argv)
|
|
36
|
+
|
|
37
|
+
command = argv.shift
|
|
38
|
+
abort(global.to_s) unless command
|
|
39
|
+
|
|
40
|
+
case command
|
|
41
|
+
when 'status' then print_json(Server::Service.build(config_path: config_path).status)
|
|
42
|
+
when 'migrate' then print_json(ok: Server::Service.build(config_path: config_path).migrate)
|
|
43
|
+
when 'commit', 'compose', 'search', 'fact-check', 'brief', 'wake-up'
|
|
44
|
+
data_cmd(command, argv, config_path)
|
|
45
|
+
when 'knowledge' then knowledge_cmd(argv, config_path)
|
|
46
|
+
when 'kg' then kg_cmd(argv, config_path)
|
|
47
|
+
when 'serve' then serve(argv, config_path)
|
|
48
|
+
when 'mcp' then mcp(config_path)
|
|
49
|
+
else
|
|
50
|
+
warn "unknown command: #{command}"
|
|
51
|
+
warn global.to_s
|
|
52
|
+
exit 1
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def data_cmd(command, argv, config_path)
|
|
57
|
+
options = {}
|
|
58
|
+
parser = OptionParser.new do |opts|
|
|
59
|
+
opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
|
|
60
|
+
opts.on('-h', '--help') { puts opts; exit 0 }
|
|
61
|
+
end
|
|
62
|
+
parser.parse!(argv)
|
|
63
|
+
|
|
64
|
+
payload = read_payload(options[:data])
|
|
65
|
+
service = Server::Service.build(config_path: config_path)
|
|
66
|
+
result =
|
|
67
|
+
case command
|
|
68
|
+
when 'commit' then service.commit(**payload.slice(:domain_id, :session_id, :scope_context, :turn_events).merge(turn_events: payload[:turn_events] || {}))
|
|
69
|
+
when 'compose' then service.compose(**payload.slice(:domain_id, :session_id, :scope_context, :user_message, :agent_state).merge(agent_state: payload[:agent_state] || {}))
|
|
70
|
+
when 'search' then service.search(**payload.slice(:domain_id, :session_id, :scope_context, :query, :limit))
|
|
71
|
+
when 'fact-check' then service.fact_check(**payload.slice(:domain_id, :session_id, :scope_context, :text))
|
|
72
|
+
when 'brief' then service.brief(**payload.slice(:domain_id, :session_id, :scope_context, :query))
|
|
73
|
+
when 'wake-up' then service.wake_up(**payload.slice(:domain_id, :session_id, :scope_context))
|
|
74
|
+
end
|
|
75
|
+
print_json(result)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def knowledge_cmd(argv, config_path)
|
|
79
|
+
options = {}
|
|
80
|
+
OptionParser.new do |opts|
|
|
81
|
+
opts.banner = 'usage: smart_brain knowledge <distill|gate|promote|demote|retract|promote-to-scope|lineage|events> --data JSON'
|
|
82
|
+
opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
|
|
83
|
+
opts.on('-h', '--help') { puts opts; exit 0 }
|
|
84
|
+
end.parse!(argv)
|
|
85
|
+
|
|
86
|
+
action = argv.shift
|
|
87
|
+
abort 'usage: smart_brain knowledge <distill|gate|promote|demote|retract|promote-to-scope|lineage|events> --data JSON' unless action
|
|
88
|
+
|
|
89
|
+
payload = read_payload(options[:data])
|
|
90
|
+
service = Server::Service.build(config_path: config_path)
|
|
91
|
+
result =
|
|
92
|
+
case action
|
|
93
|
+
when 'distill' then service.distill(**payload.slice(:domain_id, :session_id, :scope_context, :statement, :content, :tier, :supporting_refs, :domain, :field, :reviewer, :key))
|
|
94
|
+
when 'gate' then service.gate(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
|
|
95
|
+
when 'promote' then service.promote(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :verification_refs, :reason, :reviewer, :force))
|
|
96
|
+
when 'demote' then service.demote(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :evidence_refs, :reason, :reason_type, :reviewer))
|
|
97
|
+
when 'retract' then service.retract(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :evidence_refs, :reason, :reviewer))
|
|
98
|
+
when 'promote-to-scope' then service.promote_to_scope(**payload.slice(:memory_item_id, :target_scope, :domain_id, :session_id, :scope_context, :verification_refs, :reason, :reviewer))
|
|
99
|
+
when 'lineage' then service.promotion_lineage(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
|
|
100
|
+
when 'events' then service.knowledge_events(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
|
|
101
|
+
else abort "unknown knowledge action: #{action}"
|
|
102
|
+
end
|
|
103
|
+
print_json(result)
|
|
104
|
+
rescue SmartBrain::Governance::LifecycleGateError, ArgumentError => e
|
|
105
|
+
warn "#{e.class}: #{e.message}"
|
|
106
|
+
exit 1
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def kg_cmd(argv, config_path)
|
|
110
|
+
options = {}
|
|
111
|
+
OptionParser.new do |opts|
|
|
112
|
+
opts.banner = 'usage: smart_brain kg <add|query|timeline|invalidate|stats> --data JSON'
|
|
113
|
+
opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
|
|
114
|
+
opts.on('-h', '--help') { puts opts; exit 0 }
|
|
115
|
+
end.parse!(argv)
|
|
116
|
+
action = argv.shift
|
|
117
|
+
abort 'usage: smart_brain kg <add|query|timeline|invalidate|stats> --data JSON' unless action
|
|
118
|
+
|
|
119
|
+
payload = read_payload(options[:data])
|
|
120
|
+
service = Server::Service.build(config_path: config_path)
|
|
121
|
+
result =
|
|
122
|
+
case action
|
|
123
|
+
when 'add' then service.kg_add(**payload.slice(:domain_id, :session_id, :scope_context, :scope_ref, :subject, :predicate, :object, :confidence, :source_turn_id))
|
|
124
|
+
when 'query' then service.kg_query(**payload.slice(:domain_id, :session_id, :scope_context, :subject, :predicate, :object, :include_invalid))
|
|
125
|
+
when 'timeline' then service.kg_timeline(**payload.slice(:domain_id, :session_id, :scope_context, :subject))
|
|
126
|
+
when 'invalidate' then service.kg_invalidate(**payload.slice(:edge_id, :reason, :domain_id, :session_id, :scope_context))
|
|
127
|
+
when 'stats' then service.kg_stats(**payload.slice(:domain_id, :session_id, :scope_context))
|
|
128
|
+
else abort "unknown kg action: #{action}"
|
|
129
|
+
end
|
|
130
|
+
print_json(result)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def serve(argv, config_path)
|
|
134
|
+
options = { host: '127.0.0.1', port: 9292 }
|
|
135
|
+
OptionParser.new do |opts|
|
|
136
|
+
opts.on('--host HOST') { |h| options[:host] = h }
|
|
137
|
+
opts.on('--port PORT', Integer) { |p| options[:port] = p }
|
|
138
|
+
opts.on('-h', '--help') { puts opts; exit 0 }
|
|
139
|
+
end.parse!(argv)
|
|
140
|
+
|
|
141
|
+
service = Server::Service.build(config_path: config_path)
|
|
142
|
+
Server::HttpApp.service = service
|
|
143
|
+
warn "[smart_brain] HTTP on http://#{options[:host]}:#{options[:port]} (backend: #{service.backend})"
|
|
144
|
+
Server::HttpApp.run!(host: options[:host], port: options[:port], server: :puma) do |server|
|
|
145
|
+
# Puma-specific tuning can go here.
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def mcp(config_path)
|
|
150
|
+
service = Server::Service.build(config_path: config_path)
|
|
151
|
+
Server::McpServer.new(service: service).run
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def read_payload(raw)
|
|
155
|
+
return {} if raw.nil? && $stdin.tty?
|
|
156
|
+
raw ||= $stdin.read
|
|
157
|
+
raw.to_s.strip.empty? ? {} : JSON.parse(raw, symbolize_names: true)
|
|
158
|
+
rescue JSON::ParserError => e
|
|
159
|
+
abort "invalid JSON payload: #{e.message}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def print_json(payload)
|
|
163
|
+
puts JSON.generate(payload)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
SmartBrain::CLI.run(ARGV)
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'scope_filter'
|
|
4
|
+
|
|
3
5
|
module SmartBrain
|
|
4
6
|
module Adapters
|
|
5
7
|
module SmartRag
|
|
6
8
|
class DirectClient
|
|
7
|
-
|
|
9
|
+
include ScopeFilter
|
|
10
|
+
|
|
11
|
+
def initialize(rag:, scope_mapper: nil, fail_closed: true)
|
|
8
12
|
@rag = rag
|
|
13
|
+
@scope_mapper = scope_mapper
|
|
14
|
+
@fail_closed = fail_closed
|
|
9
15
|
end
|
|
10
16
|
|
|
11
17
|
def retrieve(plan)
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
|
|
19
|
+
return scope_failure_pack(plan, ignored, warnings, prefix: 'scope-blocked') unless scoped_plan
|
|
20
|
+
|
|
21
|
+
response = rag.retrieve(plan: scoped_plan)
|
|
22
|
+
pack = normalize_pack(response, request_id: plan[:request_id])
|
|
23
|
+
enforce_scope_confirmation(pack, required: required, ignored: ignored, warnings: warnings)
|
|
14
24
|
rescue StandardError => e
|
|
15
25
|
{
|
|
16
26
|
version: '0.1',
|
|
@@ -26,7 +36,7 @@ module SmartBrain
|
|
|
26
36
|
|
|
27
37
|
private
|
|
28
38
|
|
|
29
|
-
attr_reader :rag
|
|
39
|
+
attr_reader :rag, :scope_mapper, :fail_closed
|
|
30
40
|
|
|
31
41
|
def normalize_pack(response, request_id:)
|
|
32
42
|
pack = response.is_a?(Hash) ? response : {}
|
|
@@ -38,7 +48,8 @@ module SmartBrain
|
|
|
38
48
|
evidences: Array(pack[:evidences]),
|
|
39
49
|
stats: pack[:stats] || { candidates: Array(pack[:evidences]).size, returned: Array(pack[:evidences]).size, took_ms: 0 },
|
|
40
50
|
explain: pack[:explain] || { ignored_fields: [] },
|
|
41
|
-
warnings: Array(pack[:warnings])
|
|
51
|
+
warnings: Array(pack[:warnings]),
|
|
52
|
+
scope_filter_applied: pack[:scope_filter_applied] == true
|
|
42
53
|
}
|
|
43
54
|
end
|
|
44
55
|
end
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'scope_filter'
|
|
4
|
+
|
|
3
5
|
module SmartBrain
|
|
4
6
|
module Adapters
|
|
5
7
|
module SmartRag
|
|
6
8
|
class HttpClient
|
|
7
|
-
|
|
9
|
+
include ScopeFilter
|
|
10
|
+
|
|
11
|
+
def initialize(transport:, timeout_seconds: 2, scope_mapper: nil, fail_closed: true)
|
|
8
12
|
@transport = transport
|
|
9
13
|
@timeout_seconds = timeout_seconds
|
|
14
|
+
@scope_mapper = scope_mapper
|
|
15
|
+
@fail_closed = fail_closed
|
|
10
16
|
end
|
|
11
17
|
|
|
12
18
|
def retrieve(plan)
|
|
13
19
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
|
|
21
|
+
return scope_failure_pack(plan, ignored, warnings, prefix: 'scope-blocked') unless scoped_plan
|
|
22
|
+
|
|
23
|
+
raw = transport.call(scoped_plan, timeout_seconds: timeout_seconds)
|
|
24
|
+
pack = build_pack(raw: raw, request_id: plan[:request_id], took_ms: elapsed_ms(started_at))
|
|
25
|
+
enforce_scope_confirmation(pack, required: required, ignored: ignored, warnings: warnings)
|
|
16
26
|
rescue Timeout::Error
|
|
17
27
|
{
|
|
18
28
|
version: '0.1',
|
|
@@ -28,7 +38,7 @@ module SmartBrain
|
|
|
28
38
|
|
|
29
39
|
private
|
|
30
40
|
|
|
31
|
-
attr_reader :transport, :timeout_seconds
|
|
41
|
+
attr_reader :transport, :timeout_seconds, :scope_mapper, :fail_closed
|
|
32
42
|
|
|
33
43
|
def build_pack(raw:, request_id:, took_ms:)
|
|
34
44
|
ignored = []
|
|
@@ -48,7 +58,8 @@ module SmartBrain
|
|
|
48
58
|
explain: {
|
|
49
59
|
ignored_fields: ignored + Array(raw.dig(:explain, :ignored_fields))
|
|
50
60
|
},
|
|
51
|
-
warnings: Array(raw[:warnings])
|
|
61
|
+
warnings: Array(raw[:warnings]),
|
|
62
|
+
scope_filter_applied: raw[:scope_filter_applied] == true
|
|
52
63
|
}
|
|
53
64
|
end
|
|
54
65
|
|
|
@@ -5,6 +5,9 @@ module SmartBrain
|
|
|
5
5
|
module SmartRag
|
|
6
6
|
class NullClient
|
|
7
7
|
def retrieve(plan)
|
|
8
|
+
business_scoped = Array(plan.dig(:scope_context, :read)).any? do |ref|
|
|
9
|
+
(ref[:type] || ref['type']).to_s != 'session'
|
|
10
|
+
end
|
|
8
11
|
{
|
|
9
12
|
version: '0.1',
|
|
10
13
|
plan_id: "local-#{plan[:request_id]}",
|
|
@@ -12,8 +15,10 @@ module SmartBrain
|
|
|
12
15
|
generated_at: Time.now.utc.iso8601,
|
|
13
16
|
evidences: [],
|
|
14
17
|
stats: { candidates: 0, returned: 0, took_ms: 0 },
|
|
15
|
-
explain: { ignored_fields: [] },
|
|
16
|
-
warnings: ['smart_rag client not configured;
|
|
18
|
+
explain: { ignored_fields: business_scoped ? ['scope_context.read'] : [] },
|
|
19
|
+
warnings: [business_scoped ? 'smart_rag scope filter unavailable: client not configured; failed closed' :
|
|
20
|
+
'smart_rag client not configured; returned empty evidences'],
|
|
21
|
+
scope_filter_applied: !business_scoped
|
|
17
22
|
}
|
|
18
23
|
end
|
|
19
24
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Adapters
|
|
5
|
+
module SmartRag
|
|
6
|
+
module ScopeFilter
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def prepare_scoped_plan(plan)
|
|
10
|
+
refs = Array(plan.dig(:scope_context, :read)).reject { |ref| (ref[:type] || ref['type']).to_s == 'session' }
|
|
11
|
+
return [plan, false, [], []] if refs.empty?
|
|
12
|
+
|
|
13
|
+
unless scope_mapper
|
|
14
|
+
warning = 'smart_rag scope filter unavailable: no scope mapper configured'
|
|
15
|
+
return [nil, true, ['scope_context.read'], [warning]] if fail_closed
|
|
16
|
+
return [plan, true, ['scope_context.read'], [warning]]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
mapped = scope_mapper.call(
|
|
20
|
+
domain_id: plan.dig(:debug, :caller, :domain_id),
|
|
21
|
+
scopes: refs
|
|
22
|
+
)
|
|
23
|
+
applied = mapped.fetch(:applied, true)
|
|
24
|
+
filters = mapped[:filters] || mapped.reject { |key, _value| %i[applied warnings ignored_fields].include?(key) }
|
|
25
|
+
warnings = Array(mapped[:warnings])
|
|
26
|
+
ignored = Array(mapped[:ignored_fields])
|
|
27
|
+
return [nil, true, ignored + ['scope_context.read'], warnings + ['smart_rag scope mapper did not apply filters']] if !applied && fail_closed
|
|
28
|
+
|
|
29
|
+
[plan.merge(scope_filters: filters), true, ignored, warnings]
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
warning = "smart_rag scope mapping failed: #{e.message}"
|
|
32
|
+
fail_closed ? [nil, true, ['scope_context.read'], [warning]] : [plan, true, ['scope_context.read'], [warning]]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def scope_failure_pack(plan, ignored, warnings, prefix:)
|
|
36
|
+
{
|
|
37
|
+
version: '0.2', request_id: plan[:request_id], plan_id: "#{prefix}-#{plan[:request_id]}",
|
|
38
|
+
generated_at: Time.now.utc.iso8601, evidences: [],
|
|
39
|
+
stats: { candidates: 0, returned: 0, took_ms: 0 },
|
|
40
|
+
explain: { ignored_fields: ignored }, warnings: warnings,
|
|
41
|
+
scope_filter_applied: false
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enforce_scope_confirmation(pack, required:, ignored:, warnings:)
|
|
46
|
+
confirmed = pack[:scope_filter_applied] == true
|
|
47
|
+
pack[:explain] ||= { ignored_fields: [] }
|
|
48
|
+
pack[:explain][:ignored_fields] = Array(pack.dig(:explain, :ignored_fields)) + ignored
|
|
49
|
+
pack[:warnings] = Array(pack[:warnings]) + warnings
|
|
50
|
+
return pack unless required && !confirmed
|
|
51
|
+
|
|
52
|
+
pack[:explain][:ignored_fields] << 'scope_context.read'
|
|
53
|
+
pack[:warnings] << 'smart_rag did not confirm scope filter application'
|
|
54
|
+
pack[:evidences] = [] if fail_closed
|
|
55
|
+
pack
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -37,5 +37,62 @@ module SmartBrain
|
|
|
37
37
|
def observability
|
|
38
38
|
policies.fetch(:observability, {})
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
def scopes
|
|
42
|
+
policies.fetch(:scopes, {})
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def allowed_scope_types
|
|
46
|
+
scopes.fetch(:allowed_types, %w[global project expert task session]).map(&:to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def storage
|
|
50
|
+
raw.fetch(:storage, {})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def llm
|
|
54
|
+
raw.fetch(:model_provider, {})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def llm_provider
|
|
58
|
+
(ENV['SMARTBRAIN_LLM_PROVIDER'] || llm.fetch(:provider, 'stub')).to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tiers
|
|
62
|
+
raw.fetch(:tiers, {})
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def lifecycle
|
|
66
|
+
raw.fetch(:lifecycle, {})
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def kg
|
|
70
|
+
raw.fetch(:kg, {})
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fact_check
|
|
74
|
+
raw.fetch(:fact_check, {})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def briefing
|
|
78
|
+
raw.fetch(:briefing, {})
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def storage_backend
|
|
82
|
+
(ENV['SMARTBRAIN_BACKEND'] || storage.fetch(:backend, 'memory')).to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def database_config
|
|
86
|
+
cfg = (storage.fetch(:database, {}) || {}).dup
|
|
87
|
+
%i[host port database user password].each do |key|
|
|
88
|
+
env_key = "SMARTBRAIN_DB_#{key.to_s.upcase}"
|
|
89
|
+
cfg[key] = ENV[env_key] if ENV.key?(env_key)
|
|
90
|
+
end
|
|
91
|
+
cfg
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def fts_config
|
|
95
|
+
storage.fetch(:fts_config, nil) || database_config.fetch(:fts_config, 'simple')
|
|
96
|
+
end
|
|
40
97
|
end
|
|
41
98
|
end
|
|
@@ -1,42 +1,103 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../model_provider/stub'
|
|
4
|
+
|
|
3
5
|
module SmartBrain
|
|
4
6
|
module Consolidator
|
|
7
|
+
# WorkingSummary is store-backed: summaries persist via the MemoryStore
|
|
8
|
+
# (in-memory hash for MemoryStore::InMemory, the summaries table for
|
|
9
|
+
# MemoryStore::Postgres), so they survive runtime restarts on the PG path.
|
|
10
|
+
# The last-summary turn is derived from the persisted summary's source range.
|
|
11
|
+
#
|
|
12
|
+
# When a real LLM ModelProvider is wired, the summary text is generated by
|
|
13
|
+
# the model (qwen3 etc.); otherwise it falls back to the deterministic
|
|
14
|
+
# template (Stub default), so behavior is stable and offline-friendly.
|
|
5
15
|
class WorkingSummary
|
|
6
|
-
|
|
16
|
+
SUMMARY_SYSTEM = 'You are a memory consolidator. Produce a concise rolling ' \
|
|
17
|
+
'summary in the exact section structure given. Skip empty ' \
|
|
18
|
+
'sections. No preamble, no markdown fences.'
|
|
19
|
+
|
|
20
|
+
def initialize(config:, clock:, memory_store:, model_provider: SmartBrain::ModelProvider::Stub.new)
|
|
7
21
|
@config = config
|
|
8
22
|
@clock = clock
|
|
9
|
-
@
|
|
10
|
-
@
|
|
23
|
+
@memory_store = memory_store
|
|
24
|
+
@model_provider = model_provider
|
|
11
25
|
end
|
|
12
26
|
|
|
13
27
|
def update(session_id:, turn_count:, recent_turns:, memory_items:, stage_event: false)
|
|
14
28
|
reason = trigger_reason(session_id: session_id, turn_count: turn_count, recent_turns: recent_turns, stage_event: stage_event)
|
|
15
|
-
|
|
29
|
+
unless reason
|
|
30
|
+
return latest_summary(session_id).merge(triggered: false, trigger_reason: 'not_triggered')
|
|
31
|
+
end
|
|
16
32
|
|
|
33
|
+
text, method = build_summary_text(memory_items: memory_items, recent_turns: recent_turns)
|
|
17
34
|
summary = {
|
|
18
35
|
summary_version: next_version(session_id),
|
|
19
36
|
summary_source_turn_range: source_turn_range(turn_count),
|
|
20
37
|
summary_generated_at: clock.call.iso8601,
|
|
21
|
-
text:
|
|
38
|
+
text: text,
|
|
39
|
+
summary_method: method,
|
|
22
40
|
triggered: true,
|
|
23
41
|
trigger_reason: reason
|
|
24
42
|
}
|
|
25
|
-
|
|
26
|
-
last_summary_turn[session_id] = turn_count
|
|
43
|
+
memory_store.save_summary(session_id: session_id, summary: summary)
|
|
27
44
|
summary
|
|
28
45
|
end
|
|
29
46
|
|
|
30
47
|
def latest_summary(session_id)
|
|
31
|
-
|
|
48
|
+
memory_store.latest_summary(session_id: session_id) || default_summary
|
|
32
49
|
end
|
|
33
50
|
|
|
34
51
|
private
|
|
35
52
|
|
|
36
|
-
attr_reader :config, :clock, :
|
|
53
|
+
attr_reader :config, :clock, :memory_store, :model_provider
|
|
54
|
+
|
|
55
|
+
def build_summary_text(memory_items:, recent_turns:)
|
|
56
|
+
if model_provider&.llm?
|
|
57
|
+
text = llm_summary(memory_items: memory_items, recent_turns: recent_turns)
|
|
58
|
+
return [text, 'llm'] unless text.nil? || text.strip.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
[build_text(memory_items), 'template']
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def llm_summary(memory_items:, recent_turns:)
|
|
65
|
+
result = model_provider.complete(
|
|
66
|
+
prompt: summary_prompt(memory_items: memory_items, recent_turns: recent_turns),
|
|
67
|
+
system: SUMMARY_SYSTEM,
|
|
68
|
+
temperature: config.llm.fetch(:temperature, 0.2),
|
|
69
|
+
max_tokens: config.llm.fetch(:summary_max_tokens, 900)
|
|
70
|
+
)
|
|
71
|
+
result[:error] ? nil : result[:text]
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def summary_prompt(memory_items:, recent_turns:)
|
|
77
|
+
convo = Array(recent_turns).map { |t| "#{t[:role]}: #{t[:content]}" }.join("\n")
|
|
78
|
+
mem = memory_items.group_by { |i| i[:type] }
|
|
79
|
+
.map { |type, items| "#{type}: #{items.first(8).map { |i| i[:key] }.join(', ')}" }
|
|
80
|
+
.join("\n")
|
|
81
|
+
<<~PROMPT
|
|
82
|
+
<conversation>
|
|
83
|
+
#{convo}
|
|
84
|
+
</conversation>
|
|
85
|
+
|
|
86
|
+
<memory>
|
|
87
|
+
#{mem}
|
|
88
|
+
</memory>
|
|
89
|
+
|
|
90
|
+
Write a concise rolling summary of the conversation above using ONLY these sections (omit a section if empty, terse bullets, cite memory keys). Do not echo the input or add preamble:
|
|
91
|
+
Goals:
|
|
92
|
+
Decisions:
|
|
93
|
+
Tasks:
|
|
94
|
+
Key References:
|
|
95
|
+
Open Questions:
|
|
96
|
+
PROMPT
|
|
97
|
+
end
|
|
37
98
|
|
|
38
99
|
def trigger_reason(session_id:, turn_count:, recent_turns:, stage_event:)
|
|
39
|
-
turns_since_last = turn_count - last_summary_turn
|
|
100
|
+
turns_since_last = turn_count - last_summary_turn(session_id)
|
|
40
101
|
threshold = config.retention.fetch(:summarize_after_turns, 12)
|
|
41
102
|
return 'turn_threshold' if turns_since_last >= threshold
|
|
42
103
|
|
|
@@ -48,13 +109,20 @@ module SmartBrain
|
|
|
48
109
|
nil
|
|
49
110
|
end
|
|
50
111
|
|
|
112
|
+
def last_summary_turn(session_id)
|
|
113
|
+
summary = memory_store.latest_summary(session_id: session_id)
|
|
114
|
+
return 0 unless summary
|
|
115
|
+
|
|
116
|
+
(summary[:summary_source_turn_range] || {})[:to].to_i
|
|
117
|
+
end
|
|
118
|
+
|
|
51
119
|
def estimate_tokens(recent_turns)
|
|
52
120
|
recent_turns.sum { |t| t[:content].to_s.length / 4 }
|
|
53
121
|
end
|
|
54
122
|
|
|
55
123
|
def next_version(session_id)
|
|
56
|
-
previous =
|
|
57
|
-
previous ? previous[:summary_version] + 1 : 1
|
|
124
|
+
previous = memory_store.latest_summary(session_id: session_id)
|
|
125
|
+
previous ? previous[:summary_version].to_i + 1 : 1
|
|
58
126
|
end
|
|
59
127
|
|
|
60
128
|
def source_turn_range(turn_count)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require_relative '../governance/tiers'
|
|
4
5
|
|
|
5
6
|
module SmartBrain
|
|
6
7
|
module ContextComposer
|
|
@@ -10,9 +11,10 @@ module SmartBrain
|
|
|
10
11
|
@clock = clock
|
|
11
12
|
end
|
|
12
13
|
|
|
13
|
-
def compose(session_id:, user_message:, plan:, plan_id:, summary:, recent_turns:, evidence_bundle
|
|
14
|
+
def compose(session_id:, user_message:, plan:, plan_id:, summary:, recent_turns:, evidence_bundle:,
|
|
15
|
+
domain_id: 'legacy', scope_context: nil)
|
|
14
16
|
context_id = SecureRandom.uuid
|
|
15
|
-
evidence = evidence_bundle.fetch(:selected, [])
|
|
17
|
+
evidence = apply_tier_order(evidence_bundle.fetch(:selected, []))
|
|
16
18
|
used_estimate = estimate_tokens(summary: summary[:text], recent_turns: recent_turns, evidence: evidence, user_message: user_message)
|
|
17
19
|
token_limit = config.composition.fetch(:token_limit, 8192)
|
|
18
20
|
|
|
@@ -20,6 +22,8 @@ module SmartBrain
|
|
|
20
22
|
version: '0.1',
|
|
21
23
|
context_id: context_id,
|
|
22
24
|
session_id: session_id,
|
|
25
|
+
domain_id: domain_id,
|
|
26
|
+
scope_context: scope_context,
|
|
23
27
|
created_at: clock.call.iso8601,
|
|
24
28
|
system_blocks: [],
|
|
25
29
|
developer_blocks: [],
|
|
@@ -54,7 +58,14 @@ module SmartBrain
|
|
|
54
58
|
},
|
|
55
59
|
why_selected: evidence.map { |e| "#{e[:id]} score=#{e[:score]} source=#{e[:source]}" },
|
|
56
60
|
ignored: evidence_bundle[:ignored_fields] || [],
|
|
57
|
-
dropped: (evidence_bundle[:dropped] || []).map { |e| { id: e[:id], reason: e[:drop_reason] } }
|
|
61
|
+
dropped: (evidence_bundle[:dropped] || []).map { |e| { id: e[:id], reason: e[:drop_reason] } },
|
|
62
|
+
scopes_read: Array(scope_context && scope_context[:read]),
|
|
63
|
+
scope_stats: evidence_bundle[:scope_stats] || {},
|
|
64
|
+
shadowed: (evidence_bundle[:shadowed] || []).map do |e|
|
|
65
|
+
e.slice(:id, :memory_type, :memory_key, :scope, :shadow_reason, :shadowed_by)
|
|
66
|
+
end,
|
|
67
|
+
scope_budget: evidence_bundle[:scope_budget] || {},
|
|
68
|
+
scope_filter_warnings: evidence_bundle[:scope_filter_warnings] || []
|
|
58
69
|
}
|
|
59
70
|
}
|
|
60
71
|
end
|
|
@@ -70,6 +81,32 @@ module SmartBrain
|
|
|
70
81
|
text_size += user_message.to_s.length
|
|
71
82
|
(text_size / 4.0).ceil
|
|
72
83
|
end
|
|
84
|
+
|
|
85
|
+
# Order evidence by mind-model tier (dao_tian → dao_ren → shu → qi →
|
|
86
|
+
# evidence), preserving score order within a tier, and cap immutable
|
|
87
|
+
# dao_tian principles at dao_tian_limit.
|
|
88
|
+
def apply_tier_order(evidence)
|
|
89
|
+
dao_tian_limit = begin
|
|
90
|
+
Governance::Tiers.dao_tian_limit(config)
|
|
91
|
+
rescue StandardError
|
|
92
|
+
1
|
|
93
|
+
end
|
|
94
|
+
stable = evidence.each_with_index
|
|
95
|
+
sorted = stable.sort_by do |e, idx|
|
|
96
|
+
[Governance::Tiers.priority(e[:tier] || 'evidence'), -(e[:score] || 0.0), idx]
|
|
97
|
+
end
|
|
98
|
+
result = []
|
|
99
|
+
dao_tian_count = 0
|
|
100
|
+
sorted.each do |e, _idx|
|
|
101
|
+
if (e[:tier] || 'evidence') == 'dao_tian'
|
|
102
|
+
next if dao_tian_count >= dao_tian_limit
|
|
103
|
+
|
|
104
|
+
dao_tian_count += 1
|
|
105
|
+
end
|
|
106
|
+
result << e
|
|
107
|
+
end
|
|
108
|
+
result
|
|
109
|
+
end
|
|
73
110
|
end
|
|
74
111
|
end
|
|
75
112
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'scope_context'
|
|
4
|
+
|
|
3
5
|
module SmartBrain
|
|
4
6
|
module Contracts
|
|
5
7
|
class RetrievalPlan
|
|
@@ -10,6 +12,14 @@ module SmartBrain
|
|
|
10
12
|
raise ArgumentError, "invalid retrieval plan: missing #{missing.join(', ')}" unless missing.empty?
|
|
11
13
|
raise ArgumentError, 'invalid retrieval plan: queries must not be empty' if Array(plan[:queries]).empty?
|
|
12
14
|
|
|
15
|
+
if plan[:scope_context]
|
|
16
|
+
ScopeContext.normalize(
|
|
17
|
+
domain_id: plan.dig(:debug, :caller, :domain_id),
|
|
18
|
+
session_id: plan.dig(:debug, :caller, :session_id) || plan[:session_id],
|
|
19
|
+
scope_context: plan[:scope_context]
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
13
23
|
true
|
|
14
24
|
end
|
|
15
25
|
end
|