smart_brain 0.2.0 → 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.
@@ -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,6 +4,8 @@ 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)
8
10
  business_scoped = Array(plan.dig(:scope_context, :read)).any? do |ref|
9
11
  (ref[:type] || ref['type']).to_s != 'session'
@@ -21,6 +23,41 @@ module SmartBrain
21
23
  scope_filter_applied: !business_scoped
22
24
  }
23
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
24
61
  end
25
62
  end
26
63
  end
@@ -85,7 +85,10 @@ module SmartBrain
85
85
  def database_config
86
86
  cfg = (storage.fetch(:database, {}) || {}).dup
87
87
  %i[host port database user password].each do |key|
88
- env_key = "SMARTBRAIN_DB_#{key.to_s.upcase}"
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}"
89
92
  cfg[key] = ENV[env_key] if ENV.key?(env_key)
90
93
  end
91
94
  cfg
@@ -264,7 +264,8 @@ module SmartBrain
264
264
  result
265
265
  end
266
266
 
267
- def compose_context(session_id:, user_message:, agent_state: {}, domain_id: nil, scope_context: nil)
267
+ def compose_context(session_id:, user_message:, agent_state: {}, domain_id: nil, scope_context: nil,
268
+ resource_filters: {})
268
269
  started_at = monotonic_time
269
270
  resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
270
271
  recent_turns = event_store.recent_turns(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
@@ -279,6 +280,7 @@ module SmartBrain
279
280
  refs: refs
280
281
  )
281
282
  plan[:scope_context] = public_scope_context(resolved)
283
+ plan[:global_filters] = plan.fetch(:global_filters, {}).merge(symbolize_keys(resource_filters))
282
284
  plan[:debug] = (plan[:debug] || {}).merge(
283
285
  caller: { app: 'smart_brain', domain_id: resolved[:domain_id], session_id: session_id }
284
286
  )
@@ -363,6 +365,18 @@ module SmartBrain
363
365
  conflict_resolver.resolve(result)[:selected].first(limit)
364
366
  end
365
367
 
368
+ def add_document(source:, options: {}) = smart_rag_client.add_document(source, options)
369
+ def add_media(source:, options: {}) = smart_rag_client.add_media(source, options)
370
+ def add_image(source:, options: {}) = smart_rag_client.add_image(source, options)
371
+ def add_audio(source:, options: {}) = smart_rag_client.add_audio(source, options)
372
+ def add_video(source:, options: {}) = smart_rag_client.add_video(source, options)
373
+ def enqueue_media(source:, options: {}) = smart_rag_client.enqueue_media(source, options)
374
+ def media_job(job_id:) = smart_rag_client.media_job(job_id)
375
+ def media_jobs(status: nil, limit: 20, offset: 0) = smart_rag_client.media_jobs(status: status, limit: limit, offset: offset)
376
+ def cancel_media_job(job_id:) = smart_rag_client.cancel_media_job(job_id)
377
+ def retry_media_job(job_id:) = smart_rag_client.retry_media_job(job_id)
378
+ def media_job_statistics = smart_rag_client.media_job_statistics
379
+
366
380
  def memory_item(id:)
367
381
  memory_store.find_item(id: id)
368
382
  end
@@ -413,6 +427,10 @@ module SmartBrain
413
427
  raise
414
428
  end
415
429
 
430
+ def symbolize_keys(hash)
431
+ hash.each_with_object({}) { |(key, value), result| result[key.to_sym] = value }
432
+ end
433
+
416
434
  def scope_validation_failure!(message)
417
435
  error = ArgumentError.new(message)
418
436
  tracker.log_scope_validation_failure(error)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmartBrain
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/smart_brain.rb CHANGED
@@ -18,13 +18,26 @@ module SmartBrain
18
18
  runtime.commit_turn(session_id: session_id, turn_events: turn_events, domain_id: domain_id, scope_context: scope_context)
19
19
  end
20
20
 
21
- def compose_context(session_id:, user_message:, agent_state: {}, domain_id: nil, scope_context: nil)
21
+ def compose_context(session_id:, user_message:, agent_state: {}, domain_id: nil, scope_context: nil,
22
+ resource_filters: {})
22
23
  runtime.compose_context(
23
24
  session_id: session_id, user_message: user_message, agent_state: agent_state,
24
- domain_id: domain_id, scope_context: scope_context
25
+ domain_id: domain_id, scope_context: scope_context, resource_filters: resource_filters
25
26
  )
26
27
  end
27
28
 
29
+ def add_document(source:, options: {}) = runtime.add_document(source: source, options: options)
30
+ def add_media(source:, options: {}) = runtime.add_media(source: source, options: options)
31
+ def add_image(source:, options: {}) = runtime.add_image(source: source, options: options)
32
+ def add_audio(source:, options: {}) = runtime.add_audio(source: source, options: options)
33
+ def add_video(source:, options: {}) = runtime.add_video(source: source, options: options)
34
+ def enqueue_media(source:, options: {}) = runtime.enqueue_media(source: source, options: options)
35
+ def media_job(job_id:) = runtime.media_job(job_id: job_id)
36
+ def media_jobs(status: nil, limit: 20, offset: 0) = runtime.media_jobs(status: status, limit: limit, offset: offset)
37
+ def cancel_media_job(job_id:) = runtime.cancel_media_job(job_id: job_id)
38
+ def retry_media_job(job_id:) = runtime.retry_media_job(job_id: job_id)
39
+ def media_job_statistics = runtime.media_job_statistics
40
+
28
41
  def search_memory(session_id:, query:, limit: nil, domain_id: nil, scope_context: nil)
29
42
  runtime.search_memory(
30
43
  session_id: session_id, query: query, limit: limit, domain_id: domain_id, scope_context: scope_context
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_brain
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SmartBrain Team
@@ -9,34 +9,6 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: dry-struct
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - "~>"
17
- - !ruby/object:Gem::Version
18
- version: '1.7'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - "~>"
24
- - !ruby/object:Gem::Version
25
- version: '1.7'
26
- - !ruby/object:Gem::Dependency
27
- name: dry-validation
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '1.11'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '1.11'
40
12
  - !ruby/object:Gem::Dependency
41
13
  name: faraday
42
14
  requirement: !ruby/object:Gem::Requirement
@@ -52,19 +24,19 @@ dependencies:
52
24
  - !ruby/object:Gem::Version
53
25
  version: '2.11'
54
26
  - !ruby/object:Gem::Dependency
55
- name: oj
27
+ name: faraday-multipart
56
28
  requirement: !ruby/object:Gem::Requirement
57
29
  requirements:
58
30
  - - "~>"
59
31
  - !ruby/object:Gem::Version
60
- version: '3.16'
32
+ version: '1.0'
61
33
  type: :runtime
62
34
  prerelease: false
63
35
  version_requirements: !ruby/object:Gem::Requirement
64
36
  requirements:
65
37
  - - "~>"
66
38
  - !ruby/object:Gem::Version
67
- version: '3.16'
39
+ version: '1.0'
68
40
  - !ruby/object:Gem::Dependency
69
41
  name: pg
70
42
  requirement: !ruby/object:Gem::Requirement
@@ -94,47 +66,47 @@ dependencies:
94
66
  - !ruby/object:Gem::Version
95
67
  version: '7.2'
96
68
  - !ruby/object:Gem::Dependency
97
- name: sequel
69
+ name: rackup
98
70
  requirement: !ruby/object:Gem::Requirement
99
71
  requirements:
100
72
  - - "~>"
101
73
  - !ruby/object:Gem::Version
102
- version: '5.87'
74
+ version: '2.1'
103
75
  type: :runtime
104
76
  prerelease: false
105
77
  version_requirements: !ruby/object:Gem::Requirement
106
78
  requirements:
107
79
  - - "~>"
108
80
  - !ruby/object:Gem::Version
109
- version: '5.87'
81
+ version: '2.1'
110
82
  - !ruby/object:Gem::Dependency
111
- name: sinatra
83
+ name: sequel
112
84
  requirement: !ruby/object:Gem::Requirement
113
85
  requirements:
114
86
  - - "~>"
115
87
  - !ruby/object:Gem::Version
116
- version: '4.2'
88
+ version: '5.87'
117
89
  type: :runtime
118
90
  prerelease: false
119
91
  version_requirements: !ruby/object:Gem::Requirement
120
92
  requirements:
121
93
  - - "~>"
122
94
  - !ruby/object:Gem::Version
123
- version: '4.2'
95
+ version: '5.87'
124
96
  - !ruby/object:Gem::Dependency
125
- name: smart_rag
97
+ name: sinatra
126
98
  requirement: !ruby/object:Gem::Requirement
127
99
  requirements:
128
100
  - - "~>"
129
101
  - !ruby/object:Gem::Version
130
- version: '0.1'
102
+ version: '4.2'
131
103
  type: :runtime
132
104
  prerelease: false
133
105
  version_requirements: !ruby/object:Gem::Requirement
134
106
  requirements:
135
107
  - - "~>"
136
108
  - !ruby/object:Gem::Version
137
- version: '0.1'
109
+ version: '4.2'
138
110
  - !ruby/object:Gem::Dependency
139
111
  name: bundler
140
112
  requirement: !ruby/object:Gem::Requirement
@@ -259,7 +231,9 @@ files:
259
231
  - docs/context_package.md
260
232
  - docs/evidence_pack.md
261
233
  - docs/gap_vs_mempal.md
234
+ - docs/installation.md
262
235
  - docs/mcp.md
236
+ - docs/media_memory_schema.md
263
237
  - docs/memory_types.md
264
238
  - docs/multi_scope_memory_refactor_plan.md
265
239
  - docs/multi_scope_migration.md
@@ -274,11 +248,14 @@ files:
274
248
  - examples/04_ollama_llm.rb
275
249
  - examples/05_smart_rag_integration.rb
276
250
  - examples/06_multi_scope_memory.rb
251
+ - examples/07_media_memory.rb
277
252
  - examples/README.md
278
253
  - exe/smart_brain
279
254
  - lib/smart_brain.rb
280
255
  - lib/smart_brain/adapters/smart_rag/direct_client.rb
281
256
  - lib/smart_brain/adapters/smart_rag/http_client.rb
257
+ - lib/smart_brain/adapters/smart_rag/http_transport.rb
258
+ - lib/smart_brain/adapters/smart_rag/media_metadata_extractor.rb
282
259
  - lib/smart_brain/adapters/smart_rag/null_client.rb
283
260
  - lib/smart_brain/adapters/smart_rag/scope_filter.rb
284
261
  - lib/smart_brain/configuration.rb
@@ -329,6 +306,23 @@ licenses:
329
306
  metadata:
330
307
  source_code_uri: https://github.com/zhuangbiaowei/smart_brain
331
308
  changelog_uri: https://github.com/zhuangbiaowei/smart_brain/blob/master/CHANGELOG.md
309
+ documentation_uri: https://github.com/zhuangbiaowei/smart_brain/blob/master/docs/user_guide.md
310
+ post_install_message: |
311
+ SmartBrain 已安装(0.3.0)。默认零外部依赖:memory 后端 + stub LLM。
312
+
313
+ 快速开始:
314
+ smart_brain --version
315
+ smart_brain status
316
+ ruby -e "require 'smart_brain'; SmartBrain.configure; p SmartBrain.compose_context(session_id: 's', user_message: '你好')"
317
+
318
+ 常用配置(环境变量):
319
+ SMARTBRAIN_BACKEND=postgres # 持久化(需先建库,或 smart_brain migrate)
320
+ SMARTBRAIN_DB_NAME=smart_brain_development # 其余 SMARTBRAIN_DB_{HOST,PORT,USER,PASSWORD}
321
+ SMARTBRAIN_LLM_PROVIDER=ollama # 真 LLM 摘要/重排(需本地 ollama + 生成式模型如 qwen3/llama2)
322
+ SMARTBRAIN_LLM_BASE_URL / SMARTBRAIN_LLM_API_KEY / SMARTBRAIN_LLM_MODEL
323
+
324
+ 资源 RAG(可选):gem install smart_rag,再用 SmartBrain::Adapters::SmartRag::DirectClient / HttpClient。
325
+ 详见 README 与 docs/user_guide.md、docs/installation.md。
332
326
  rdoc_options: []
333
327
  require_paths:
334
328
  - lib
@@ -343,7 +337,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
343
337
  - !ruby/object:Gem::Version
344
338
  version: '0'
345
339
  requirements: []
346
- rubygems_version: 4.0.13
340
+ rubygems_version: 4.0.16
347
341
  specification_version: 4
348
342
  summary: Agent memory runtime and context composer
349
343
  test_files: []