smart_brain 0.1.2 → 0.3.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 +25 -0
- data/MEMPAL_GUIDE.md +1074 -0
- data/README.en.md +206 -173
- data/README.md +593 -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/installation.md +198 -0
- data/docs/mcp.md +93 -0
- data/docs/media_memory_schema.md +271 -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 +233 -0
- data/docs/smartbrain_design.md +299 -0
- data/docs/user_guide.md +547 -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/07_media_memory.rb +53 -0
- data/examples/README.md +49 -0
- data/exe/smart_brain +168 -0
- data/lib/smart_brain/adapters/smart_rag/direct_client.rb +57 -5
- data/lib/smart_brain/adapters/smart_rag/http_client.rb +118 -5
- data/lib/smart_brain/adapters/smart_rag/http_transport.rb +138 -0
- data/lib/smart_brain/adapters/smart_rag/media_metadata_extractor.rb +255 -0
- data/lib/smart_brain/adapters/smart_rag/null_client.rb +44 -2
- data/lib/smart_brain/adapters/smart_rag/scope_filter.rb +60 -0
- data/lib/smart_brain/configuration.rb +60 -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 +306 -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 +93 -35
- metadata +100 -54
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module SmartBrain
|
|
7
|
+
module Adapters
|
|
8
|
+
module SmartRag
|
|
9
|
+
# Extracts technical metadata from media files (image / audio / video).
|
|
10
|
+
#
|
|
11
|
+
# Stage 1 of the media memory plan (docs/media_memory_schema.md):
|
|
12
|
+
# - image -> Python Pillow bridge (same pattern as MarkitdownBridge)
|
|
13
|
+
# - audio -> ffprobe
|
|
14
|
+
# - video -> ffprobe
|
|
15
|
+
#
|
|
16
|
+
# Degradation policy (D5): when a dependency is missing the extractor
|
|
17
|
+
# returns the default media object with a warning instead of raising,
|
|
18
|
+
# so ingestion can still store file-level metadata.
|
|
19
|
+
#
|
|
20
|
+
# NOTE: This component lives in smart_brain during stage 1 (testable
|
|
21
|
+
# without a SmartRAG database). Stage 3 will sync it into the SmartRAG
|
|
22
|
+
# project as SmartRAG::Core::MediaMetadataExtractor and wire it into
|
|
23
|
+
# DocumentProcessor#extract_metadata.
|
|
24
|
+
class MediaMetadataExtractor
|
|
25
|
+
IMAGE_EXTS = %w[.jpg .jpeg .png .gif .webp .bmp .tif .tiff .heic .heif].freeze
|
|
26
|
+
AUDIO_EXTS = %w[.mp3 .wav .m4a .aac .flac .ogg .oga .opus .wma .aif .aiff].freeze
|
|
27
|
+
VIDEO_EXTS = %w[.mp4 .mkv .mov .webm .avi .flv .wmv .m4v .ts .mpeg .mpg].freeze
|
|
28
|
+
|
|
29
|
+
# Wide schema shared by all three media types; type-specific fields
|
|
30
|
+
# stay nil (design decision D1).
|
|
31
|
+
DEFAULT_MEDIA = {
|
|
32
|
+
format: nil,
|
|
33
|
+
width: nil,
|
|
34
|
+
height: nil,
|
|
35
|
+
dpi: nil,
|
|
36
|
+
color_mode: nil,
|
|
37
|
+
orientation: nil,
|
|
38
|
+
exif: {},
|
|
39
|
+
duration_ms: nil,
|
|
40
|
+
bitrate_bps: nil,
|
|
41
|
+
sample_rate_hz: nil,
|
|
42
|
+
channels: nil,
|
|
43
|
+
codec: nil,
|
|
44
|
+
fps: nil,
|
|
45
|
+
audio_codec: nil
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# Extract technical metadata from a media file.
|
|
49
|
+
# @param [String] file_path path to the media file
|
|
50
|
+
# @param [String, Symbol, nil] media_type explicit type override
|
|
51
|
+
# @return [Hash] { media_type:, media: {...}, warnings: [...] }
|
|
52
|
+
def extract(file_path, media_type: nil)
|
|
53
|
+
detected = media_type || detect_media_type(file_path)
|
|
54
|
+
return { media_type: 'other', media: DEFAULT_MEDIA.dup, warnings: [] } unless detected
|
|
55
|
+
|
|
56
|
+
type = detected.to_sym
|
|
57
|
+
media = DEFAULT_MEDIA.dup
|
|
58
|
+
warnings = []
|
|
59
|
+
|
|
60
|
+
case type
|
|
61
|
+
when :image then extract_image(file_path, media, warnings)
|
|
62
|
+
when :audio then extract_audio(file_path, media, warnings)
|
|
63
|
+
when :video then extract_video(file_path, media, warnings)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
{ media_type: type.to_s, media: media, warnings: warnings }
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
{
|
|
69
|
+
media_type: (type ? type.to_s : 'other'),
|
|
70
|
+
media: (defined?(media) && media) || DEFAULT_MEDIA.dup,
|
|
71
|
+
warnings: ["media metadata extraction failed: #{e.message}"]
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Detect media type from file extension.
|
|
76
|
+
# @param [String] file_path
|
|
77
|
+
# @return [Symbol, nil] :image | :audio | :video | nil (unknown)
|
|
78
|
+
def detect_media_type(file_path)
|
|
79
|
+
ext = File.extname(file_path.to_s).downcase
|
|
80
|
+
return :image if IMAGE_EXTS.include?(ext)
|
|
81
|
+
return :audio if AUDIO_EXTS.include?(ext)
|
|
82
|
+
return :video if VIDEO_EXTS.include?(ext)
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check whether the image dependency (Pillow) is available.
|
|
88
|
+
def pillow_available?
|
|
89
|
+
return @pillow_available unless @pillow_available.nil?
|
|
90
|
+
|
|
91
|
+
@pillow_available = system('python3', '-c', 'import PIL', out: File::NULL, err: File::NULL) ||
|
|
92
|
+
system('python', '-c', 'import PIL', out: File::NULL, err: File::NULL)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check whether ffprobe is available.
|
|
96
|
+
def ffprobe_available?
|
|
97
|
+
return @ffprobe_available unless @ffprobe_available.nil?
|
|
98
|
+
|
|
99
|
+
@ffprobe_available = !ffprobe_cmd.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def extract_image(file_path, media, warnings)
|
|
105
|
+
unless pillow_available?
|
|
106
|
+
warnings << 'Pillow (PIL) not available; image metadata skipped'
|
|
107
|
+
return media
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
python = %w[python3 python].find { |cmd| system(cmd, '-c', 'import PIL', out: File::NULL, err: File::NULL) }
|
|
111
|
+
script = <<~PYTHON
|
|
112
|
+
import json, sys
|
|
113
|
+
from PIL import Image
|
|
114
|
+
|
|
115
|
+
path = sys.argv[1]
|
|
116
|
+
img = Image.open(path)
|
|
117
|
+
info = {
|
|
118
|
+
'format': (img.format or '').lower() or None,
|
|
119
|
+
'width': img.width,
|
|
120
|
+
'height': img.height,
|
|
121
|
+
'color_mode': img.mode,
|
|
122
|
+
}
|
|
123
|
+
dpi = img.info.get('dpi')
|
|
124
|
+
info['dpi'] = round(dpi[0]) if dpi and isinstance(dpi[0], (int, float))
|
|
125
|
+
exif = {}
|
|
126
|
+
try:
|
|
127
|
+
from PIL import ExifTags
|
|
128
|
+
raw = img._getexif() if hasattr(img, '_getexif') else None
|
|
129
|
+
if raw:
|
|
130
|
+
for k, v in raw.items():
|
|
131
|
+
name = ExifTags.TAGS.get(k, str(k))
|
|
132
|
+
if v is None or isinstance(v, (int, float, str)):
|
|
133
|
+
exif[name] = v
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
info['exif'] = exif
|
|
137
|
+
print(json.dumps(info))
|
|
138
|
+
PYTHON
|
|
139
|
+
|
|
140
|
+
output, status = Open3.capture2e(python, '-c', script, file_path)
|
|
141
|
+
unless status.success?
|
|
142
|
+
warnings << "image metadata extraction failed: #{output.strip}"
|
|
143
|
+
return media
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
parsed = JSON.parse(output)
|
|
147
|
+
media[:format] = parsed['format']
|
|
148
|
+
media[:width] = parsed['width']
|
|
149
|
+
media[:height] = parsed['height']
|
|
150
|
+
media[:color_mode] = parsed['color_mode']
|
|
151
|
+
media[:dpi] = parsed['dpi']
|
|
152
|
+
media[:exif] = parsed['exif'] || {}
|
|
153
|
+
media[:orientation] = derive_orientation(media[:width], media[:height])
|
|
154
|
+
media
|
|
155
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
156
|
+
warnings << "image metadata extraction failed: #{e.message}"
|
|
157
|
+
media
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def extract_audio(file_path, media, warnings)
|
|
161
|
+
probe = probe_ffprobe(file_path, warnings)
|
|
162
|
+
return media unless probe
|
|
163
|
+
|
|
164
|
+
format_info = probe['format'] || {}
|
|
165
|
+
stream = probe['streams'].to_a.find { |s| s['codec_type'] == 'audio' } || probe['streams'].to_a.first
|
|
166
|
+
|
|
167
|
+
media[:format] = File.extname(file_path.to_s).downcase.sub('.', '')
|
|
168
|
+
media[:codec] = stream && stream['codec_name']
|
|
169
|
+
media[:duration_ms] = ms_of(format_info['duration'])
|
|
170
|
+
media[:bitrate_bps] = int_of(format_info['bit_rate']) || (stream && int_of(stream['bit_rate']))
|
|
171
|
+
media[:sample_rate_hz] = stream && int_of(stream['sample_rate'])
|
|
172
|
+
media[:channels] = stream && int_of(stream['channels'])
|
|
173
|
+
media
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def extract_video(file_path, media, warnings)
|
|
177
|
+
probe = probe_ffprobe(file_path, warnings)
|
|
178
|
+
return media unless probe
|
|
179
|
+
|
|
180
|
+
format_info = probe['format'] || {}
|
|
181
|
+
streams = probe['streams'].to_a
|
|
182
|
+
video_stream = streams.find { |s| s['codec_type'] == 'video' }
|
|
183
|
+
audio_stream = streams.find { |s| s['codec_type'] == 'audio' }
|
|
184
|
+
|
|
185
|
+
media[:format] = File.extname(file_path.to_s).downcase.sub('.', '')
|
|
186
|
+
media[:duration_ms] = ms_of(format_info['duration'])
|
|
187
|
+
media[:bitrate_bps] = int_of(format_info['bit_rate'])
|
|
188
|
+
if video_stream
|
|
189
|
+
media[:width] = int_of(video_stream['width'])
|
|
190
|
+
media[:height] = int_of(video_stream['height'])
|
|
191
|
+
media[:codec] = video_stream['codec_name']
|
|
192
|
+
media[:fps] = fps_of(video_stream)
|
|
193
|
+
end
|
|
194
|
+
media[:audio_codec] = audio_stream && audio_stream['codec_name']
|
|
195
|
+
media[:orientation] = derive_orientation(media[:width], media[:height])
|
|
196
|
+
media
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def probe_ffprobe(file_path, warnings)
|
|
200
|
+
cmd = ffprobe_cmd
|
|
201
|
+
unless cmd
|
|
202
|
+
warnings << 'ffprobe not available; audio/video metadata skipped'
|
|
203
|
+
return nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
args = [cmd, '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', file_path]
|
|
207
|
+
output, status, = Open3.capture3(*args)
|
|
208
|
+
unless status.success?
|
|
209
|
+
warnings << "ffprobe failed: #{output.strip}"
|
|
210
|
+
return nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
JSON.parse(output)
|
|
214
|
+
rescue JSON::ParserError, SystemCallError, Errno::ENOENT => e
|
|
215
|
+
warnings << "ffprobe failed: #{e.message}"
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def ffprobe_cmd
|
|
220
|
+
@ffprobe_cmd ||= %w[ffprobe].find { |c| system('which', c, out: File::NULL, err: File::NULL) }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def ms_of(value)
|
|
224
|
+
return nil if value.nil?
|
|
225
|
+
|
|
226
|
+
(value.to_f * 1000).round
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def int_of(value)
|
|
230
|
+
return nil if value.nil?
|
|
231
|
+
|
|
232
|
+
Integer(value.to_s, exception: false)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def fps_of(stream)
|
|
236
|
+
rate = stream['avg_frame_rate'].to_s
|
|
237
|
+
return nil if rate.empty?
|
|
238
|
+
|
|
239
|
+
num, den = rate.split('/')
|
|
240
|
+
return nil if num.nil?
|
|
241
|
+
|
|
242
|
+
den = '1' if den.to_s.empty? || den.to_i.zero?
|
|
243
|
+
(num.to_f / den.to_f).round(2)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def derive_orientation(width, height)
|
|
247
|
+
return nil unless width && height
|
|
248
|
+
return 'square' if width == height
|
|
249
|
+
|
|
250
|
+
width > height ? 'landscape' : 'portrait'
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -4,7 +4,12 @@ module SmartBrain
|
|
|
4
4
|
module Adapters
|
|
5
5
|
module SmartRag
|
|
6
6
|
class NullClient
|
|
7
|
+
WRITE_OPERATIONS = %i[document media image audio video].freeze
|
|
8
|
+
|
|
7
9
|
def retrieve(plan)
|
|
10
|
+
business_scoped = Array(plan.dig(:scope_context, :read)).any? do |ref|
|
|
11
|
+
(ref[:type] || ref['type']).to_s != 'session'
|
|
12
|
+
end
|
|
8
13
|
{
|
|
9
14
|
version: '0.1',
|
|
10
15
|
plan_id: "local-#{plan[:request_id]}",
|
|
@@ -12,10 +17,47 @@ module SmartBrain
|
|
|
12
17
|
generated_at: Time.now.utc.iso8601,
|
|
13
18
|
evidences: [],
|
|
14
19
|
stats: { candidates: 0, returned: 0, took_ms: 0 },
|
|
15
|
-
explain: { ignored_fields: [] },
|
|
16
|
-
warnings: ['smart_rag client not configured;
|
|
20
|
+
explain: { ignored_fields: business_scoped ? ['scope_context.read'] : [] },
|
|
21
|
+
warnings: [business_scoped ? 'smart_rag scope filter unavailable: client not configured; failed closed' :
|
|
22
|
+
'smart_rag client not configured; returned empty evidences'],
|
|
23
|
+
scope_filter_applied: !business_scoped
|
|
17
24
|
}
|
|
18
25
|
end
|
|
26
|
+
|
|
27
|
+
WRITE_OPERATIONS.each do |type|
|
|
28
|
+
define_method("add_#{type}") do |_source, _options = {}|
|
|
29
|
+
{ status: 'unsupported', media_type: type.to_s, section_count: 0,
|
|
30
|
+
warnings: ["smart_rag client not configured; add_#{type} ignored"] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def enqueue_media(_source, _options = {})
|
|
35
|
+
{ status: 'unsupported', warnings: ['smart_rag client not configured; enqueue_media ignored'] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def media_job(job_id)
|
|
39
|
+
{ status: 'unsupported', job_id: job_id, warnings: ['smart_rag client not configured; media_job ignored'] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def media_jobs(status: nil, limit: 20, offset: 0)
|
|
43
|
+
{ status: 'unsupported', jobs: [], total: 0,
|
|
44
|
+
warnings: ['smart_rag client not configured; media_jobs ignored'] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cancel_media_job(job_id)
|
|
48
|
+
{ status: 'unsupported', job_id: job_id,
|
|
49
|
+
warnings: ['smart_rag client not configured; cancel_media_job ignored'] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def retry_media_job(job_id)
|
|
53
|
+
{ status: 'unsupported', job_id: job_id,
|
|
54
|
+
warnings: ['smart_rag client not configured; retry_media_job ignored'] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def media_job_statistics
|
|
58
|
+
{ status: 'unsupported', counts: {}, total: 0,
|
|
59
|
+
warnings: ['smart_rag client not configured; media_job_statistics ignored'] }
|
|
60
|
+
end
|
|
19
61
|
end
|
|
20
62
|
end
|
|
21
63
|
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,65 @@ 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
|
+
# The `database` key maps to SMARTBRAIN_DB_NAME to match the documented
|
|
89
|
+
# env contract (SMARTBRAIN_DB_{HOST,PORT,NAME,USER,PASSWORD}).
|
|
90
|
+
suffix = key == :database ? 'NAME' : key.to_s.upcase
|
|
91
|
+
env_key = "SMARTBRAIN_DB_#{suffix}"
|
|
92
|
+
cfg[key] = ENV[env_key] if ENV.key?(env_key)
|
|
93
|
+
end
|
|
94
|
+
cfg
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fts_config
|
|
98
|
+
storage.fetch(:fts_config, nil) || database_config.fetch(:fts_config, 'simple')
|
|
99
|
+
end
|
|
40
100
|
end
|
|
41
101
|
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
|