smart_brain 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.en.md +173 -0
- data/README.md +173 -0
- data/agents/brain_assistant.rb +11 -0
- data/config/brain.yml +32 -0
- data/conversation_demo.rb +438 -0
- data/db/migrate/001_init.sql +98 -0
- data/example.rb +91 -0
- data/lib/smart_brain/adapters/smart_rag/direct_client.rb +47 -0
- data/lib/smart_brain/adapters/smart_rag/http_client.rb +61 -0
- data/lib/smart_brain/adapters/smart_rag/null_client.rb +22 -0
- data/lib/smart_brain/configuration.rb +41 -0
- data/lib/smart_brain/consolidator/working_summary.rb +102 -0
- data/lib/smart_brain/context_composer/composer.rb +75 -0
- data/lib/smart_brain/contracts/context_package.rb +16 -0
- data/lib/smart_brain/contracts/evidence_pack.rb +16 -0
- data/lib/smart_brain/contracts/retrieval_plan.rb +17 -0
- data/lib/smart_brain/event_store/in_memory.rb +103 -0
- data/lib/smart_brain/fusion/merger.rb +137 -0
- data/lib/smart_brain/memory_extractor/extractor.rb +92 -0
- data/lib/smart_brain/memory_store/in_memory.rb +78 -0
- data/lib/smart_brain/observability/tracker.rb +60 -0
- data/lib/smart_brain/retrieval_planner/planner.rb +122 -0
- data/lib/smart_brain/retrievers/exact_retriever.rb +62 -0
- data/lib/smart_brain/retrievers/memory_retriever.rb +30 -0
- data/lib/smart_brain/retrievers/relational_retriever.rb +53 -0
- data/lib/smart_brain/runtime.rb +195 -0
- data/lib/smart_brain/version.rb +5 -0
- data/lib/smart_brain.rb +35 -0
- data/templates/brain_assistant.erb +5 -0
- data/workers/brain_assistant.rb +9 -0
- metadata +283 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module SmartBrain
|
|
6
|
+
module MemoryStore
|
|
7
|
+
class InMemory
|
|
8
|
+
OVERWRITE_TYPES = %w[preferences goals tasks].freeze
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@by_session = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
@entities_index = Hash.new { |h, k| h[k] = [] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def upsert(extracted)
|
|
16
|
+
session_id = extracted.fetch(:session_id)
|
|
17
|
+
items = extracted.fetch(:items, [])
|
|
18
|
+
written = []
|
|
19
|
+
conflicts = []
|
|
20
|
+
|
|
21
|
+
items.each do |item|
|
|
22
|
+
existing = active_item(session_id: session_id, type: item[:type], key: item[:key])
|
|
23
|
+
|
|
24
|
+
if existing && item[:status] == 'retracted'
|
|
25
|
+
existing[:status] = 'retracted'
|
|
26
|
+
conflicts << { type: 'retract', key: item[:key], previous_memory_item_id: existing[:id] }
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if existing && OVERWRITE_TYPES.include?(item[:type])
|
|
31
|
+
existing[:status] = 'superseded'
|
|
32
|
+
conflicts << { type: 'overwrite', key: item[:key], previous_memory_item_id: existing[:id] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
record = item.merge(id: SecureRandom.uuid, status: item[:status] || 'active')
|
|
36
|
+
by_session[session_id] << record
|
|
37
|
+
update_entities(session_id: session_id, record: record)
|
|
38
|
+
written << record.slice(:id, :type, :key, :status, :confidence)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{ count: written.size, items: written, conflicts: conflicts }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def active_items(session_id:)
|
|
45
|
+
by_session[session_id].select { |item| item[:status] == 'active' }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def entities(session_id:)
|
|
49
|
+
entities_index[session_id]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :by_session, :entities_index
|
|
55
|
+
|
|
56
|
+
def active_item(session_id:, type:, key:)
|
|
57
|
+
by_session[session_id].find { |row| row[:type] == type && row[:key] == key && row[:status] == 'active' }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_entities(session_id:, record:)
|
|
61
|
+
return unless record[:type] == 'entities'
|
|
62
|
+
|
|
63
|
+
value = record[:value_json]
|
|
64
|
+
canonical = value[:canonical] || value[:name]
|
|
65
|
+
existing = entities_index[session_id].find { |e| e[:canonical] == canonical && e[:kind] == value[:kind] }
|
|
66
|
+
return if existing
|
|
67
|
+
|
|
68
|
+
entities_index[session_id] << {
|
|
69
|
+
id: SecureRandom.uuid,
|
|
70
|
+
name: value[:name] || canonical,
|
|
71
|
+
kind: value[:kind] || 'other',
|
|
72
|
+
canonical: canonical,
|
|
73
|
+
memory_item_id: record[:id]
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Observability
|
|
5
|
+
class Tracker
|
|
6
|
+
def initialize
|
|
7
|
+
@compose_logs = []
|
|
8
|
+
@commit_logs = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def log_compose(payload)
|
|
12
|
+
compose_logs << payload
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def log_commit(payload)
|
|
16
|
+
commit_logs << payload
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def snapshot
|
|
20
|
+
{
|
|
21
|
+
compose_logs: compose_logs,
|
|
22
|
+
commit_logs: commit_logs,
|
|
23
|
+
metrics: {
|
|
24
|
+
compose_p95_ms: p95(compose_logs.map { |l| l[:took_ms] }),
|
|
25
|
+
memory_resource_ratio: memory_resource_ratio,
|
|
26
|
+
token_over_budget_rate: token_over_budget_rate
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :compose_logs, :commit_logs
|
|
34
|
+
|
|
35
|
+
def p95(values)
|
|
36
|
+
values = values.compact.sort
|
|
37
|
+
return 0 if values.empty?
|
|
38
|
+
|
|
39
|
+
index = [(values.length * 0.95).ceil - 1, 0].max
|
|
40
|
+
values[index]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def memory_resource_ratio
|
|
44
|
+
selected = compose_logs.flat_map { |l| l[:selected_evidence] || [] }
|
|
45
|
+
return '0/0' if selected.empty?
|
|
46
|
+
|
|
47
|
+
memory = selected.count { |e| e[:source] == 'memory' }
|
|
48
|
+
resource = selected.count { |e| e[:source] == 'resource' }
|
|
49
|
+
"#{memory}/#{resource}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def token_over_budget_rate
|
|
53
|
+
return 0.0 if compose_logs.empty?
|
|
54
|
+
|
|
55
|
+
over = compose_logs.count { |l| l[:token_used].to_i > l[:token_limit].to_i }
|
|
56
|
+
(over.to_f / compose_logs.length).round(3)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module RetrievalPlanner
|
|
5
|
+
class Planner
|
|
6
|
+
RESOURCE_HINTS = ['查资料', '引用', 'reference', 'research', '标准', '论文', '文档', 'compare', '对比', '来源'].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(config:)
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def plan(request_id:, session_id:, user_message:, agent_state:, recent_turns:, refs:)
|
|
13
|
+
queries = build_queries(user_message)
|
|
14
|
+
plan = {
|
|
15
|
+
version: '0.1',
|
|
16
|
+
request_id: request_id,
|
|
17
|
+
purpose: infer_purpose(user_message),
|
|
18
|
+
queries: queries,
|
|
19
|
+
global_filters: {},
|
|
20
|
+
budget: {
|
|
21
|
+
top_k: config.retrieval.fetch(:top_k, 30),
|
|
22
|
+
candidate_k: config.retrieval.fetch(:candidate_k, 200),
|
|
23
|
+
per_mode_k: {
|
|
24
|
+
exact: 10,
|
|
25
|
+
semantic: 10,
|
|
26
|
+
hybrid: config.retrieval.fetch(:top_k, 30),
|
|
27
|
+
relational: 10,
|
|
28
|
+
associative: 8
|
|
29
|
+
},
|
|
30
|
+
diversity: {
|
|
31
|
+
by_document: config.composition.dig(:diversity, :by_document) || 3,
|
|
32
|
+
by_source: config.composition.dig(:diversity, :by_source_uri) || 2
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
output: {
|
|
36
|
+
include_snippets: true,
|
|
37
|
+
max_snippet_chars: config.composition.fetch(:max_snippet_chars, 800)
|
|
38
|
+
},
|
|
39
|
+
resource_retrieval: {
|
|
40
|
+
enabled: resource_retrieval_enabled?(user_message: user_message, recent_turns: recent_turns, refs: refs),
|
|
41
|
+
reason: resource_reason(user_message: user_message, refs: refs)
|
|
42
|
+
},
|
|
43
|
+
debug: {
|
|
44
|
+
trace: config.observability.fetch(:trace, true),
|
|
45
|
+
caller: { app: 'smart_brain', session_id: session_id },
|
|
46
|
+
recent_turns_count: recent_turns.size,
|
|
47
|
+
agent_state: agent_state,
|
|
48
|
+
ignored_fields: []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
attach_filter_hints!(plan: plan, user_message: user_message)
|
|
53
|
+
plan
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :config
|
|
59
|
+
|
|
60
|
+
def build_queries(user_message)
|
|
61
|
+
queries = [{ text: user_message, mode: 'hybrid', weight: 1.0, filters: {}, hints: {} }]
|
|
62
|
+
return queries unless config.retrieval.dig(:query_expansion, :enabled)
|
|
63
|
+
|
|
64
|
+
max = config.retrieval.dig(:query_expansion, :max_queries) || 8
|
|
65
|
+
expansions = expansion_terms(user_message).first([max - 1, 0].max)
|
|
66
|
+
queries.concat(expansions.map.with_index do |text, idx|
|
|
67
|
+
{ text: text, mode: 'associative', weight: 0.8 - (idx * 0.05), filters: {}, hints: { expanded: true } }
|
|
68
|
+
end)
|
|
69
|
+
queries
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def expansion_terms(user_message)
|
|
73
|
+
tokens = user_message.to_s.scan(/[[:alnum:]_\-\p{Han}]+/).uniq
|
|
74
|
+
return [] if tokens.size < 2
|
|
75
|
+
|
|
76
|
+
phrases = []
|
|
77
|
+
phrases << tokens.first(3).join(' ')
|
|
78
|
+
phrases << tokens.last(3).join(' ')
|
|
79
|
+
phrases << tokens.sort.join(' ')
|
|
80
|
+
phrases.uniq.reject { |p| p == user_message }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def infer_purpose(user_message)
|
|
84
|
+
lowered = user_message.downcase
|
|
85
|
+
return 'research' if RESOURCE_HINTS.any? { |hint| lowered.include?(hint) }
|
|
86
|
+
|
|
87
|
+
'qa'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def resource_retrieval_enabled?(user_message:, recent_turns:, refs:)
|
|
91
|
+
mode = config.retrieval.fetch(:enable_resource_retrieval, 'auto')
|
|
92
|
+
return true if mode == true
|
|
93
|
+
return false if mode == false
|
|
94
|
+
|
|
95
|
+
lowered = user_message.downcase
|
|
96
|
+
return true if RESOURCE_HINTS.any? { |hint| lowered.include?(hint) }
|
|
97
|
+
return true if refs.any?
|
|
98
|
+
|
|
99
|
+
recent_turns.any? { |turn| turn[:content].to_s.match?(%r{https?://|\.(md|rb|txt)}) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resource_reason(user_message:, refs:)
|
|
103
|
+
lowered = user_message.downcase
|
|
104
|
+
return 'user_requested_external_evidence' if RESOURCE_HINTS.any? { |hint| lowered.include?(hint) }
|
|
105
|
+
return 'has_recent_refs' if refs.any?
|
|
106
|
+
|
|
107
|
+
'auto_disabled_or_not_needed'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def attach_filter_hints!(plan:, user_message:)
|
|
111
|
+
text = user_message.downcase
|
|
112
|
+
if text.include?('最近') || text.include?('recent')
|
|
113
|
+
plan[:global_filters][:time_range] = { from: (Time.now.utc - 7 * 24 * 3600).iso8601, to: Time.now.utc.iso8601 }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if text.include?('smart_rag')
|
|
117
|
+
plan[:global_filters][:source_uri_prefix] = ['smart_rag']
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Retrievers
|
|
5
|
+
class ExactRetriever
|
|
6
|
+
def retrieve(query:, memory_items:, recent_turns:, limit:)
|
|
7
|
+
terms = tokenize(query)
|
|
8
|
+
|
|
9
|
+
memory_hits = memory_items.filter_map do |item|
|
|
10
|
+
haystack = "#{item[:key]} #{item[:value_json]}".downcase
|
|
11
|
+
score = overlap_score(terms, haystack)
|
|
12
|
+
next if score <= 0
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
id: item[:id],
|
|
16
|
+
source: 'memory',
|
|
17
|
+
source_uri: "smartbrain://memory/#{item[:id]}",
|
|
18
|
+
title: item[:key],
|
|
19
|
+
snippet: item[:value_json].to_s,
|
|
20
|
+
mode: 'exact',
|
|
21
|
+
score: score + (item[:confidence] || 0.5),
|
|
22
|
+
ref: { memory_item_id: item[:id] }
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
turn_hits = recent_turns.filter_map.with_index do |turn, idx|
|
|
27
|
+
haystack = turn[:content].to_s.downcase
|
|
28
|
+
score = overlap_score(terms, haystack)
|
|
29
|
+
next if score <= 0
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
id: "turn-#{idx}",
|
|
33
|
+
source: 'memory',
|
|
34
|
+
source_uri: 'smartbrain://recent_turn',
|
|
35
|
+
title: 'Recent Turn',
|
|
36
|
+
snippet: turn[:content].to_s,
|
|
37
|
+
mode: 'exact',
|
|
38
|
+
score: score,
|
|
39
|
+
ref: { turn_id: turn[:turn_id], message_id: turn[:message_id] }
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
(memory_hits + turn_hits).sort_by { |h| -h[:score] }.first(limit)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def tokenize(text)
|
|
49
|
+
text.to_s.downcase.scan(/[[:alnum:]_\-\p{Han}]+/).uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def overlap_score(terms, haystack)
|
|
53
|
+
return 0.0 if terms.empty?
|
|
54
|
+
|
|
55
|
+
hits = terms.count { |t| haystack.include?(t) }
|
|
56
|
+
return 0.0 if hits.zero?
|
|
57
|
+
|
|
58
|
+
hits.to_f / terms.length
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'exact_retriever'
|
|
4
|
+
require_relative 'relational_retriever'
|
|
5
|
+
|
|
6
|
+
module SmartBrain
|
|
7
|
+
module Retrievers
|
|
8
|
+
class MemoryRetriever
|
|
9
|
+
def initialize(config:)
|
|
10
|
+
@config = config
|
|
11
|
+
@exact = ExactRetriever.new
|
|
12
|
+
@relational = RelationalRetriever.new(config: config)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def retrieve(query:, memory_items:, recent_turns:, entities:, refs:)
|
|
16
|
+
limit = config.composition.fetch(:evidence_max_items, 12)
|
|
17
|
+
exact_hits = exact.retrieve(query: query, memory_items: memory_items, recent_turns: recent_turns, limit: limit)
|
|
18
|
+
relational_hits = relational.retrieve(query: query, entities: entities, refs: refs, limit: limit)
|
|
19
|
+
|
|
20
|
+
(exact_hits + relational_hits)
|
|
21
|
+
.sort_by { |h| -h.fetch(:score, 0.0) }
|
|
22
|
+
.first(limit)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :config, :exact, :relational
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBrain
|
|
4
|
+
module Retrievers
|
|
5
|
+
class RelationalRetriever
|
|
6
|
+
def initialize(config:)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def retrieve(query:, entities:, refs:, limit:)
|
|
11
|
+
terms = query.to_s.downcase.scan(/[[:alnum:]_\-\p{Han}]+/)
|
|
12
|
+
entity_hits = entities.filter_map do |entity|
|
|
13
|
+
score = terms.count { |t| entity[:canonical].to_s.downcase.include?(t) || entity[:name].to_s.downcase.include?(t) }
|
|
14
|
+
next if score.zero?
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
id: "entity-#{entity[:id]}",
|
|
18
|
+
source: 'memory',
|
|
19
|
+
source_uri: "smartbrain://entity/#{entity[:id]}",
|
|
20
|
+
title: entity[:name],
|
|
21
|
+
snippet: "Entity #{entity[:kind]}: #{entity[:canonical]}",
|
|
22
|
+
mode: 'relational',
|
|
23
|
+
score: score.to_f,
|
|
24
|
+
ref: { memory_item_id: entity[:memory_item_id] }
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
ref_hits = refs.filter_map do |ref|
|
|
29
|
+
value = ref[:ref_uri].to_s.downcase
|
|
30
|
+
score = terms.count { |t| value.include?(t) }
|
|
31
|
+
next if score.zero?
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
id: "ref-#{ref[:id]}",
|
|
35
|
+
source: 'memory',
|
|
36
|
+
source_uri: ref[:ref_uri],
|
|
37
|
+
title: ref[:ref_type],
|
|
38
|
+
snippet: ref[:ref_meta_json].to_s,
|
|
39
|
+
mode: 'relational',
|
|
40
|
+
score: (score * 0.8).to_f,
|
|
41
|
+
ref: { turn_id: ref[:turn_id] }
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
(entity_hits + ref_hits).sort_by { |h| -h[:score] }.first(limit)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :config
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'contracts/retrieval_plan'
|
|
5
|
+
require_relative 'contracts/evidence_pack'
|
|
6
|
+
require_relative 'contracts/context_package'
|
|
7
|
+
require_relative 'observability/tracker'
|
|
8
|
+
require_relative 'event_store/in_memory'
|
|
9
|
+
require_relative 'memory_store/in_memory'
|
|
10
|
+
require_relative 'memory_extractor/extractor'
|
|
11
|
+
require_relative 'consolidator/working_summary'
|
|
12
|
+
require_relative 'retrieval_planner/planner'
|
|
13
|
+
require_relative 'retrievers/memory_retriever'
|
|
14
|
+
require_relative 'adapters/smart_rag/null_client'
|
|
15
|
+
require_relative 'fusion/merger'
|
|
16
|
+
require_relative 'context_composer/composer'
|
|
17
|
+
|
|
18
|
+
module SmartBrain
|
|
19
|
+
class Runtime
|
|
20
|
+
def self.build(config:, smart_rag_client: nil, clock:)
|
|
21
|
+
event_store = EventStore::InMemory.new
|
|
22
|
+
memory_store = MemoryStore::InMemory.new
|
|
23
|
+
new(
|
|
24
|
+
config: config,
|
|
25
|
+
clock: clock,
|
|
26
|
+
event_store: event_store,
|
|
27
|
+
memory_store: memory_store,
|
|
28
|
+
extractor: MemoryExtractor::Extractor.new(config: config),
|
|
29
|
+
consolidator: Consolidator::WorkingSummary.new(config: config, clock: clock),
|
|
30
|
+
planner: RetrievalPlanner::Planner.new(config: config),
|
|
31
|
+
memory_retriever: Retrievers::MemoryRetriever.new(config: config),
|
|
32
|
+
smart_rag_client: smart_rag_client || Adapters::SmartRag::NullClient.new,
|
|
33
|
+
merger: Fusion::Merger.new(config: config),
|
|
34
|
+
composer: ContextComposer::Composer.new(config: config, clock: clock),
|
|
35
|
+
tracker: Observability::Tracker.new
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(config:, clock:, event_store:, memory_store:, extractor:, consolidator:, planner:, memory_retriever:, smart_rag_client:, merger:, composer:, tracker:)
|
|
40
|
+
@config = config
|
|
41
|
+
@clock = clock
|
|
42
|
+
@event_store = event_store
|
|
43
|
+
@memory_store = memory_store
|
|
44
|
+
@extractor = extractor
|
|
45
|
+
@consolidator = consolidator
|
|
46
|
+
@planner = planner
|
|
47
|
+
@memory_retriever = memory_retriever
|
|
48
|
+
@smart_rag_client = smart_rag_client
|
|
49
|
+
@merger = merger
|
|
50
|
+
@composer = composer
|
|
51
|
+
@tracker = tracker
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def commit_turn(session_id:, turn_events:)
|
|
55
|
+
started_at = monotonic_time
|
|
56
|
+
now = clock.call
|
|
57
|
+
turn = event_store.append_turn(session_id: session_id, turn_events: turn_events, created_at: now)
|
|
58
|
+
entity_frequencies = event_store.entity_frequencies(
|
|
59
|
+
session_id: session_id,
|
|
60
|
+
window_turns: config.retention.dig(:entity_gate, :window_turns) || 20
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
extracted = extractor.extract(session_id: session_id, turn: turn, entity_frequencies: entity_frequencies)
|
|
64
|
+
write_result = memory_store.upsert(extracted)
|
|
65
|
+
turn_count = event_store.turns_count(session_id: session_id)
|
|
66
|
+
recent_turns = event_store.recent_turns(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
|
|
67
|
+
stage_event = extracted[:items].any? { |item| item[:type] == 'decisions' || (item[:type] == 'tasks' && item.dig(:value_json, :status) == 'done') }
|
|
68
|
+
summary = consolidator.update(
|
|
69
|
+
session_id: session_id,
|
|
70
|
+
turn_count: turn_count,
|
|
71
|
+
recent_turns: recent_turns,
|
|
72
|
+
memory_items: memory_store.active_items(session_id: session_id),
|
|
73
|
+
stage_event: stage_event
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
result = {
|
|
77
|
+
ok: true,
|
|
78
|
+
commit_id: SecureRandom.uuid,
|
|
79
|
+
session_id: session_id,
|
|
80
|
+
turn_id: turn[:id],
|
|
81
|
+
memory_written: write_result,
|
|
82
|
+
summary: summary,
|
|
83
|
+
explain: {
|
|
84
|
+
retention: extracted.fetch(:explain, []),
|
|
85
|
+
conflicts: write_result.fetch(:conflicts, []),
|
|
86
|
+
summary: {
|
|
87
|
+
triggered: summary[:triggered],
|
|
88
|
+
reason: summary[:trigger_reason]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
tracker.log_commit(
|
|
94
|
+
commit_id: result[:commit_id],
|
|
95
|
+
session_id: session_id,
|
|
96
|
+
turn_id: turn[:id],
|
|
97
|
+
memory_items: write_result[:items],
|
|
98
|
+
conflicts: write_result[:conflicts],
|
|
99
|
+
summary_triggered: summary[:triggered],
|
|
100
|
+
summary_reason: summary[:trigger_reason],
|
|
101
|
+
took_ms: elapsed_ms(started_at)
|
|
102
|
+
)
|
|
103
|
+
result
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def compose_context(session_id:, user_message:, agent_state: {})
|
|
107
|
+
started_at = monotonic_time
|
|
108
|
+
recent_turns = event_store.recent_turns(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
|
|
109
|
+
refs = event_store.recent_refs(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
|
|
110
|
+
request_id = SecureRandom.uuid
|
|
111
|
+
plan = planner.plan(
|
|
112
|
+
request_id: request_id,
|
|
113
|
+
session_id: session_id,
|
|
114
|
+
user_message: user_message,
|
|
115
|
+
agent_state: agent_state,
|
|
116
|
+
recent_turns: recent_turns,
|
|
117
|
+
refs: refs
|
|
118
|
+
)
|
|
119
|
+
Contracts::RetrievalPlan.validate!(plan)
|
|
120
|
+
|
|
121
|
+
memory_evidence = memory_retriever.retrieve(
|
|
122
|
+
query: user_message,
|
|
123
|
+
memory_items: memory_store.active_items(session_id: session_id),
|
|
124
|
+
recent_turns: recent_turns,
|
|
125
|
+
entities: memory_store.entities(session_id: session_id),
|
|
126
|
+
refs: refs
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
resource_pack = if plan.dig(:resource_retrieval, :enabled)
|
|
130
|
+
smart_rag_client.retrieve(plan)
|
|
131
|
+
else
|
|
132
|
+
{
|
|
133
|
+
version: '0.1',
|
|
134
|
+
request_id: request_id,
|
|
135
|
+
plan_id: "local-#{request_id}",
|
|
136
|
+
generated_at: clock.call.iso8601,
|
|
137
|
+
evidences: [],
|
|
138
|
+
explain: { ignored_fields: [] },
|
|
139
|
+
warnings: ['resource retrieval disabled by planner']
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
Contracts::EvidencePack.validate!(resource_pack)
|
|
144
|
+
merged_bundle = merger.merge(
|
|
145
|
+
query: user_message,
|
|
146
|
+
memory_evidence: memory_evidence,
|
|
147
|
+
resource_evidence: resource_pack.fetch(:evidences, [])
|
|
148
|
+
)
|
|
149
|
+
merged_bundle[:ignored_fields] = Array(merged_bundle[:ignored_fields]) + Array(resource_pack.dig(:explain, :ignored_fields))
|
|
150
|
+
|
|
151
|
+
context = composer.compose(
|
|
152
|
+
session_id: session_id,
|
|
153
|
+
user_message: user_message,
|
|
154
|
+
plan: plan,
|
|
155
|
+
plan_id: resource_pack[:plan_id],
|
|
156
|
+
summary: consolidator.latest_summary(session_id),
|
|
157
|
+
recent_turns: recent_turns,
|
|
158
|
+
evidence_bundle: merged_bundle
|
|
159
|
+
)
|
|
160
|
+
Contracts::ContextPackage.validate!(context)
|
|
161
|
+
|
|
162
|
+
took_ms = elapsed_ms(started_at)
|
|
163
|
+
token_budget = context.dig(:constraints, :token_budget) || {}
|
|
164
|
+
tracker.log_compose(
|
|
165
|
+
context_id: context[:context_id],
|
|
166
|
+
session_id: session_id,
|
|
167
|
+
request_id: request_id,
|
|
168
|
+
plan_id: resource_pack[:plan_id],
|
|
169
|
+
selected_evidence: context[:evidence],
|
|
170
|
+
ignored_fields: context.dig(:debug, :ignored),
|
|
171
|
+
token_used: token_budget[:used_estimate] || 0,
|
|
172
|
+
token_limit: token_budget[:limit] || config.composition.fetch(:token_limit, 8192),
|
|
173
|
+
took_ms: took_ms
|
|
174
|
+
)
|
|
175
|
+
context
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def diagnostics
|
|
179
|
+
tracker.snapshot.merge(turns: event_store.all_turns(session_id: nil))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
attr_reader :config, :clock, :event_store, :memory_store, :extractor, :consolidator,
|
|
185
|
+
:planner, :memory_retriever, :smart_rag_client, :merger, :composer, :tracker
|
|
186
|
+
|
|
187
|
+
def monotonic_time
|
|
188
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def elapsed_ms(start)
|
|
192
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/smart_brain.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative 'smart_brain/version'
|
|
5
|
+
require_relative 'smart_brain/configuration'
|
|
6
|
+
require_relative 'smart_brain/runtime'
|
|
7
|
+
|
|
8
|
+
module SmartBrain
|
|
9
|
+
class << self
|
|
10
|
+
attr_writer :runtime
|
|
11
|
+
|
|
12
|
+
def configure(config_path: nil, smart_rag_client: nil, clock: -> { Time.now.utc })
|
|
13
|
+
config = Configuration.load(config_path)
|
|
14
|
+
@runtime = Runtime.build(config: config, smart_rag_client: smart_rag_client, clock: clock)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def commit_turn(session_id:, turn_events:)
|
|
18
|
+
runtime.commit_turn(session_id: session_id, turn_events: turn_events)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def compose_context(session_id:, user_message:, agent_state: {})
|
|
22
|
+
runtime.compose_context(session_id: session_id, user_message: user_message, agent_state: agent_state)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def diagnostics
|
|
26
|
+
runtime.diagnostics
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def runtime
|
|
32
|
+
@runtime ||= Runtime.build(config: Configuration.load, clock: -> { Time.now.utc })
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
SmartPrompt.define_worker :brain_assistant_worker do
|
|
4
|
+
use 'silicon_flow'
|
|
5
|
+
model 'Pro/moonshotai/Kimi-K2.5'
|
|
6
|
+
sys_msg 'You are a concise assistant. Use the provided context to answer the latest user message. If evidence exists, prioritize it.'
|
|
7
|
+
prompt :brain_assistant, { text: params[:text] }
|
|
8
|
+
send_msg
|
|
9
|
+
end
|