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
|
@@ -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
|
|
@@ -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)
|