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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/MEMPAL_GUIDE.md +1074 -0
  4. data/README.en.md +206 -173
  5. data/README.md +593 -173
  6. data/config/brain.yml +69 -1
  7. data/conversation_demo.rb +438 -438
  8. data/db/migrate/002_turn_events_payload.sql +9 -0
  9. data/db/migrate/003_tiers_and_lifecycle.sql +28 -0
  10. data/db/migrate/004_kg_edges.sql +30 -0
  11. data/db/migrate/005_domains_and_memory_scopes.sql +163 -0
  12. data/docs/coding_todo.md +139 -0
  13. data/docs/context_package.md +220 -0
  14. data/docs/evidence_pack.md +190 -0
  15. data/docs/gap_vs_mempal.md +161 -0
  16. data/docs/installation.md +198 -0
  17. data/docs/mcp.md +93 -0
  18. data/docs/media_memory_schema.md +271 -0
  19. data/docs/memory_types.md +278 -0
  20. data/docs/multi_scope_memory_refactor_plan.md +483 -0
  21. data/docs/multi_scope_migration.md +65 -0
  22. data/docs/policies.md +308 -0
  23. data/docs/retrieval_plan.md +233 -0
  24. data/docs/smartbrain_design.md +299 -0
  25. data/docs/user_guide.md +547 -0
  26. data/example.rb +91 -91
  27. data/examples/01_memory_basic.rb +57 -0
  28. data/examples/02_governance.rb +63 -0
  29. data/examples/03_postgres_persistence.rb +63 -0
  30. data/examples/04_ollama_llm.rb +69 -0
  31. data/examples/05_smart_rag_integration.rb +79 -0
  32. data/examples/06_multi_scope_memory.rb +50 -0
  33. data/examples/07_media_memory.rb +53 -0
  34. data/examples/README.md +49 -0
  35. data/exe/smart_brain +168 -0
  36. data/lib/smart_brain/adapters/smart_rag/direct_client.rb +57 -5
  37. data/lib/smart_brain/adapters/smart_rag/http_client.rb +118 -5
  38. data/lib/smart_brain/adapters/smart_rag/http_transport.rb +138 -0
  39. data/lib/smart_brain/adapters/smart_rag/media_metadata_extractor.rb +255 -0
  40. data/lib/smart_brain/adapters/smart_rag/null_client.rb +44 -2
  41. data/lib/smart_brain/adapters/smart_rag/scope_filter.rb +60 -0
  42. data/lib/smart_brain/configuration.rb +60 -0
  43. data/lib/smart_brain/consolidator/working_summary.rb +80 -12
  44. data/lib/smart_brain/context_composer/composer.rb +40 -3
  45. data/lib/smart_brain/contracts/retrieval_plan.rb +10 -0
  46. data/lib/smart_brain/contracts/scope_context.rb +46 -0
  47. data/lib/smart_brain/contracts/scope_ref.rb +25 -0
  48. data/lib/smart_brain/db.rb +109 -0
  49. data/lib/smart_brain/event_store/in_memory.rb +6 -2
  50. data/lib/smart_brain/event_store/postgres.rb +199 -0
  51. data/lib/smart_brain/fusion/merger.rb +31 -2
  52. data/lib/smart_brain/governance/briefing.rb +146 -0
  53. data/lib/smart_brain/governance/fact_check.rb +110 -0
  54. data/lib/smart_brain/governance/knowledge_graph.rb +60 -0
  55. data/lib/smart_brain/governance/lifecycle.rb +225 -0
  56. data/lib/smart_brain/governance/tiers.rb +60 -0
  57. data/lib/smart_brain/memory_extractor/extractor.rb +25 -7
  58. data/lib/smart_brain/memory_store/in_memory.rb +202 -17
  59. data/lib/smart_brain/memory_store/postgres.rb +500 -0
  60. data/lib/smart_brain/model_provider/base.rb +87 -0
  61. data/lib/smart_brain/model_provider/factory.rb +49 -0
  62. data/lib/smart_brain/model_provider/ollama.rb +60 -0
  63. data/lib/smart_brain/model_provider/openai.rb +60 -0
  64. data/lib/smart_brain/model_provider/stub.rb +26 -0
  65. data/lib/smart_brain/model_provider.rb +7 -0
  66. data/lib/smart_brain/observability/tracker.rb +39 -1
  67. data/lib/smart_brain/retrievers/exact_retriever.rb +6 -0
  68. data/lib/smart_brain/retrievers/memory_retriever.rb +59 -5
  69. data/lib/smart_brain/runtime.rb +306 -16
  70. data/lib/smart_brain/scopes/conflict_resolver.rb +67 -0
  71. data/lib/smart_brain/scopes/registry.rb +133 -0
  72. data/lib/smart_brain/scopes/resolver.rb +32 -0
  73. data/lib/smart_brain/server/http_app.rb +143 -0
  74. data/lib/smart_brain/server/mcp_server.rb +385 -0
  75. data/lib/smart_brain/server/service.rb +129 -0
  76. data/lib/smart_brain/support/levenshtein.rb +35 -0
  77. data/lib/smart_brain/version.rb +5 -5
  78. data/lib/smart_brain.rb +93 -35
  79. metadata +100 -54
@@ -0,0 +1,49 @@
1
+ # SmartBrain 示例集
2
+
3
+ 每个脚本都是**自包含、可独立运行**的,从易到难、按需引入外部依赖。
4
+ 全部默认零外部依赖即可起步;PG / Ollama / SmartRAG 按场景按需开启。
5
+
6
+ ## 场景速查
7
+
8
+ | 脚本 | 场景 | 依赖 | 后端 |
9
+ |---|---|---|---|
10
+ | [`01_memory_basic.rb`](01_memory_basic.rb) | 最小闭环:commit→compose→search→brief→diagnostics | 无 | memory |
11
+ | [`02_governance.rb`](02_governance.rb) | 记忆治理:lifecycle + KG + fact_check + brief | 无 | memory |
12
+ | [`03_postgres_persistence.rb`](03_postgres_persistence.rb) | 跨「重启」持久化召回 | PostgreSQL | postgres |
13
+ | [`04_ollama_llm.rb`](04_ollama_llm.rb) | LLM 真摘要 + rerank | Ollama | memory |
14
+ | [`05_smart_rag_integration.rb`](05_smart_rag_integration.rb) | 对话记忆 + 资源 RAG 融合 | SmartRAG | memory |
15
+ | [`06_multi_scope_memory.rb`](06_multi_scope_memory.rb) | project/task 跨会话共享、冲突与 promotion | 无 | memory |
16
+
17
+ ## 运行
18
+
19
+ ```bash
20
+ cd /root/smart_brain
21
+
22
+ ruby examples/01_memory_basic.rb # 直接跑,无需任何配置
23
+ ruby examples/02_governance.rb # 直接跑
24
+
25
+ SMARTBRAIN_DB_NAME=smart_brain_test \
26
+ ruby examples/03_postgres_persistence.rb # 需先建库(见脚本顶部注释)
27
+
28
+ ruby examples/04_ollama_llm.rb # 需 ollama serve + ollama pull llama2
29
+ SMARTBRAIN_LLM_MODEL=qwen3 ruby examples/04_ollama_llm.rb # 换更强的模型(更慢)
30
+
31
+ SMARTBRAIN_RAG_DB_HOST=... SMARTBRAIN_RAG_DB_NAME=smart_rag_development \
32
+ ruby examples/05_smart_rag_integration.rb # 需 SmartRAG 实例
33
+ ruby examples/06_multi_scope_memory.rb
34
+ ```
35
+
36
+ > 没装对应依赖时,03/04/05 会**打印设置步骤并优雅退出**(不会崩)。
37
+
38
+ ## 阅读顺序建议
39
+
40
+ 1. `01_memory_basic.rb` —— 先建立 commit/compose 心智模型。
41
+ 2. `02_governance.rb` —— 理解「记忆宫殿」的分层与生命周期。
42
+ 3. 按需挑 03/04/05 之一看持久化、LLM、资源检索如何接入。
43
+
44
+ ## 参考文档
45
+
46
+ - 完整用法:[`../docs/user_guide.md`](../docs/user_guide.md)
47
+ - MCP 接入:[`../docs/mcp.md`](../docs/mcp.md)
48
+ - 设计与契约:`../docs/smartbrain_design.md`、`../docs/memory_types.md`、`../docs/policies.md`
49
+ - 完整 SmartRAG 集成 demo:`../conversation_demo.rb`、`../example.rb`
data/exe/smart_brain ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # SmartBrain CLI — drive the memory runtime from the shell or as an agent tool.
5
+ #
6
+ # smart_brain status
7
+ # smart_brain migrate (postgres backend only)
8
+ # smart_brain commit --data '{"session_id":..,"turn_events":{..}}'
9
+ # smart_brain compose --data '{"session_id":..,"user_message":..}'
10
+ # smart_brain search --data '{"session_id":..,"query":..}'
11
+ # smart_brain serve [--host 0.0.0.0] [--port 9292] [--config PATH]
12
+ # smart_brain mcp [--config PATH] (stdio JSON-RPC, for Claude Code / Cursor)
13
+
14
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
15
+
16
+ require 'optparse'
17
+ require 'json'
18
+ require 'smart_brain'
19
+ require 'smart_brain/server/service'
20
+ require 'smart_brain/server/http_app'
21
+ require 'smart_brain/server/mcp_server'
22
+
23
+ module SmartBrain
24
+ module CLI
25
+ module_function
26
+
27
+ def run(argv)
28
+ config_path = ENV['SMARTBRAIN_CONFIG']
29
+ global = OptionParser.new do |opts|
30
+ opts.banner = 'Usage: smart_brain <command> [options]'
31
+ opts.on('--config PATH', 'Path to brain.yml') { |p| config_path = p }
32
+ opts.on('--version', 'Print version and exit') { puts SmartBrain::VERSION; exit 0 }
33
+ opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
34
+ end
35
+ global.order!(argv)
36
+
37
+ command = argv.shift
38
+ abort(global.to_s) unless command
39
+
40
+ case command
41
+ when 'status' then print_json(Server::Service.build(config_path: config_path).status)
42
+ when 'migrate' then print_json(ok: Server::Service.build(config_path: config_path).migrate)
43
+ when 'commit', 'compose', 'search', 'fact-check', 'brief', 'wake-up'
44
+ data_cmd(command, argv, config_path)
45
+ when 'knowledge' then knowledge_cmd(argv, config_path)
46
+ when 'kg' then kg_cmd(argv, config_path)
47
+ when 'serve' then serve(argv, config_path)
48
+ when 'mcp' then mcp(config_path)
49
+ else
50
+ warn "unknown command: #{command}"
51
+ warn global.to_s
52
+ exit 1
53
+ end
54
+ end
55
+
56
+ def data_cmd(command, argv, config_path)
57
+ options = {}
58
+ parser = OptionParser.new do |opts|
59
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
60
+ opts.on('-h', '--help') { puts opts; exit 0 }
61
+ end
62
+ parser.parse!(argv)
63
+
64
+ payload = read_payload(options[:data])
65
+ service = Server::Service.build(config_path: config_path)
66
+ result =
67
+ case command
68
+ when 'commit' then service.commit(**payload.slice(:domain_id, :session_id, :scope_context, :turn_events).merge(turn_events: payload[:turn_events] || {}))
69
+ when 'compose' then service.compose(**payload.slice(:domain_id, :session_id, :scope_context, :user_message, :agent_state).merge(agent_state: payload[:agent_state] || {}))
70
+ when 'search' then service.search(**payload.slice(:domain_id, :session_id, :scope_context, :query, :limit))
71
+ when 'fact-check' then service.fact_check(**payload.slice(:domain_id, :session_id, :scope_context, :text))
72
+ when 'brief' then service.brief(**payload.slice(:domain_id, :session_id, :scope_context, :query))
73
+ when 'wake-up' then service.wake_up(**payload.slice(:domain_id, :session_id, :scope_context))
74
+ end
75
+ print_json(result)
76
+ end
77
+
78
+ def knowledge_cmd(argv, config_path)
79
+ options = {}
80
+ OptionParser.new do |opts|
81
+ opts.banner = 'usage: smart_brain knowledge <distill|gate|promote|demote|retract|promote-to-scope|lineage|events> --data JSON'
82
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
83
+ opts.on('-h', '--help') { puts opts; exit 0 }
84
+ end.parse!(argv)
85
+
86
+ action = argv.shift
87
+ abort 'usage: smart_brain knowledge <distill|gate|promote|demote|retract|promote-to-scope|lineage|events> --data JSON' unless action
88
+
89
+ payload = read_payload(options[:data])
90
+ service = Server::Service.build(config_path: config_path)
91
+ result =
92
+ case action
93
+ when 'distill' then service.distill(**payload.slice(:domain_id, :session_id, :scope_context, :statement, :content, :tier, :supporting_refs, :domain, :field, :reviewer, :key))
94
+ when 'gate' then service.gate(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
95
+ when 'promote' then service.promote(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :verification_refs, :reason, :reviewer, :force))
96
+ when 'demote' then service.demote(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :evidence_refs, :reason, :reason_type, :reviewer))
97
+ when 'retract' then service.retract(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :evidence_refs, :reason, :reviewer))
98
+ when 'promote-to-scope' then service.promote_to_scope(**payload.slice(:memory_item_id, :target_scope, :domain_id, :session_id, :scope_context, :verification_refs, :reason, :reviewer))
99
+ when 'lineage' then service.promotion_lineage(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
100
+ when 'events' then service.knowledge_events(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
101
+ else abort "unknown knowledge action: #{action}"
102
+ end
103
+ print_json(result)
104
+ rescue SmartBrain::Governance::LifecycleGateError, ArgumentError => e
105
+ warn "#{e.class}: #{e.message}"
106
+ exit 1
107
+ end
108
+
109
+ def kg_cmd(argv, config_path)
110
+ options = {}
111
+ OptionParser.new do |opts|
112
+ opts.banner = 'usage: smart_brain kg <add|query|timeline|invalidate|stats> --data JSON'
113
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
114
+ opts.on('-h', '--help') { puts opts; exit 0 }
115
+ end.parse!(argv)
116
+ action = argv.shift
117
+ abort 'usage: smart_brain kg <add|query|timeline|invalidate|stats> --data JSON' unless action
118
+
119
+ payload = read_payload(options[:data])
120
+ service = Server::Service.build(config_path: config_path)
121
+ result =
122
+ case action
123
+ when 'add' then service.kg_add(**payload.slice(:domain_id, :session_id, :scope_context, :scope_ref, :subject, :predicate, :object, :confidence, :source_turn_id))
124
+ when 'query' then service.kg_query(**payload.slice(:domain_id, :session_id, :scope_context, :subject, :predicate, :object, :include_invalid))
125
+ when 'timeline' then service.kg_timeline(**payload.slice(:domain_id, :session_id, :scope_context, :subject))
126
+ when 'invalidate' then service.kg_invalidate(**payload.slice(:edge_id, :reason, :domain_id, :session_id, :scope_context))
127
+ when 'stats' then service.kg_stats(**payload.slice(:domain_id, :session_id, :scope_context))
128
+ else abort "unknown kg action: #{action}"
129
+ end
130
+ print_json(result)
131
+ end
132
+
133
+ def serve(argv, config_path)
134
+ options = { host: '127.0.0.1', port: 9292 }
135
+ OptionParser.new do |opts|
136
+ opts.on('--host HOST') { |h| options[:host] = h }
137
+ opts.on('--port PORT', Integer) { |p| options[:port] = p }
138
+ opts.on('-h', '--help') { puts opts; exit 0 }
139
+ end.parse!(argv)
140
+
141
+ service = Server::Service.build(config_path: config_path)
142
+ Server::HttpApp.service = service
143
+ warn "[smart_brain] HTTP on http://#{options[:host]}:#{options[:port]} (backend: #{service.backend})"
144
+ Server::HttpApp.run!(host: options[:host], port: options[:port], server: :puma) do |server|
145
+ # Puma-specific tuning can go here.
146
+ end
147
+ end
148
+
149
+ def mcp(config_path)
150
+ service = Server::Service.build(config_path: config_path)
151
+ Server::McpServer.new(service: service).run
152
+ end
153
+
154
+ def read_payload(raw)
155
+ return {} if raw.nil? && $stdin.tty?
156
+ raw ||= $stdin.read
157
+ raw.to_s.strip.empty? ? {} : JSON.parse(raw, symbolize_names: true)
158
+ rescue JSON::ParserError => e
159
+ abort "invalid JSON payload: #{e.message}"
160
+ end
161
+
162
+ def print_json(payload)
163
+ puts JSON.generate(payload)
164
+ end
165
+ end
166
+ end
167
+
168
+ SmartBrain::CLI.run(ARGV)
@@ -1,16 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'scope_filter'
4
+
3
5
  module SmartBrain
4
6
  module Adapters
5
7
  module SmartRag
6
8
  class DirectClient
7
- def initialize(rag:)
9
+ include ScopeFilter
10
+
11
+ def initialize(rag:, scope_mapper: nil, fail_closed: true)
8
12
  @rag = rag
13
+ @scope_mapper = scope_mapper
14
+ @fail_closed = fail_closed
9
15
  end
10
16
 
11
17
  def retrieve(plan)
12
- response = rag.retrieve(plan: plan)
13
- normalize_pack(response, request_id: plan[:request_id])
18
+ scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
19
+ return scope_failure_pack(plan, ignored, warnings, prefix: 'scope-blocked') unless scoped_plan
20
+
21
+ response = rag.retrieve(plan: scoped_plan)
22
+ pack = normalize_pack(response, request_id: plan[:request_id])
23
+ enforce_scope_confirmation(pack, required: required, ignored: ignored, warnings: warnings)
14
24
  rescue StandardError => e
15
25
  {
16
26
  version: '0.1',
@@ -24,9 +34,50 @@ module SmartBrain
24
34
  }
25
35
  end
26
36
 
37
+ def add_document(source, options = {}) = write(:add_document, source, options)
38
+ def add_media(source, options = {}) = write(:add_media, source, options)
39
+ def add_image(source, options = {}) = write(:add_image, source, options)
40
+ def add_audio(source, options = {}) = write(:add_audio, source, options)
41
+ def add_video(source, options = {}) = write(:add_video, source, options)
42
+ def enqueue_media(source, options = {}) = write(:enqueue_media, source, options)
43
+
44
+ def media_job(job_id)
45
+ rag.media_job(job_id) || { status: 'not_found', job_id: job_id, warnings: ['media job not found'] }
46
+ rescue StandardError => e
47
+ { status: 'failed', job_id: job_id, warnings: ["smart_rag direct media_job failed: #{e.message}"] }
48
+ end
49
+
50
+ def media_jobs(status: nil, limit: 20, offset: 0)
51
+ rag.media_jobs(status: status, limit: limit, offset: offset)
52
+ end
53
+
54
+ def cancel_media_job(job_id) = rag.cancel_media_job(job_id)
55
+ def retry_media_job(job_id) = rag.retry_media_job(job_id)
56
+ def media_job_statistics = rag.media_job_statistics
57
+
27
58
  private
28
59
 
29
- attr_reader :rag
60
+ attr_reader :rag, :scope_mapper, :fail_closed
61
+
62
+ def write(operation, source, options)
63
+ normalize_write_result(rag.public_send(operation, source, options), operation)
64
+ rescue StandardError => e
65
+ {
66
+ status: 'failed',
67
+ media_type: operation.to_s.delete_prefix('add_'),
68
+ section_count: 0,
69
+ warnings: ["smart_rag direct #{operation} failed: #{e.message}"]
70
+ }
71
+ end
72
+
73
+ def normalize_write_result(response, operation)
74
+ result = response.is_a?(Hash) ? response.dup : {}
75
+ result[:status] ||= 'success'
76
+ result[:media_type] ||= operation.to_s.delete_prefix('add_') unless operation == :add_media
77
+ result[:section_count] ||= 0
78
+ result[:warnings] = Array(result[:warnings])
79
+ result
80
+ end
30
81
 
31
82
  def normalize_pack(response, request_id:)
32
83
  pack = response.is_a?(Hash) ? response : {}
@@ -38,7 +89,8 @@ module SmartBrain
38
89
  evidences: Array(pack[:evidences]),
39
90
  stats: pack[:stats] || { candidates: Array(pack[:evidences]).size, returned: Array(pack[:evidences]).size, took_ms: 0 },
40
91
  explain: pack[:explain] || { ignored_fields: [] },
41
- warnings: Array(pack[:warnings])
92
+ warnings: Array(pack[:warnings]),
93
+ scope_filter_applied: pack[:scope_filter_applied] == true
42
94
  }
43
95
  end
44
96
  end
@@ -1,18 +1,85 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'scope_filter'
4
+
3
5
  module SmartBrain
4
6
  module Adapters
5
7
  module SmartRag
6
8
  class HttpClient
7
- def initialize(transport:, timeout_seconds: 2)
9
+ include ScopeFilter
10
+
11
+ def initialize(transport:, write_transport: nil, job_transport: nil, job_list_transport: nil,
12
+ job_mutation_transport: nil, job_statistics_transport: nil,
13
+ timeout_seconds: 2, scope_mapper: nil, fail_closed: true)
8
14
  @transport = transport
15
+ @write_transport = write_transport
16
+ @job_transport = job_transport
17
+ @job_list_transport = job_list_transport
18
+ @job_mutation_transport = job_mutation_transport
19
+ @job_statistics_transport = job_statistics_transport
9
20
  @timeout_seconds = timeout_seconds
21
+ @scope_mapper = scope_mapper
22
+ @fail_closed = fail_closed
23
+ end
24
+
25
+ def self.for_url(base_url:, timeout_seconds: 30, headers: {}, scope_mapper: nil, fail_closed: true)
26
+ require_relative 'http_transport'
27
+ http_transport = HttpTransport.new(base_url: base_url, headers: headers)
28
+ new(
29
+ transport: http_transport.method(:retrieve),
30
+ write_transport: http_transport.method(:write),
31
+ job_transport: http_transport.method(:media_job),
32
+ job_list_transport: http_transport.method(:media_jobs),
33
+ job_mutation_transport: http_transport.method(:mutate_media_job),
34
+ job_statistics_transport: http_transport.method(:media_job_statistics),
35
+ timeout_seconds: timeout_seconds,
36
+ scope_mapper: scope_mapper,
37
+ fail_closed: fail_closed
38
+ )
39
+ end
40
+
41
+ def add_document(source, options = {}) = write(:add_document, source, options)
42
+ def add_media(source, options = {}) = write(:add_media, source, options)
43
+ def add_image(source, options = {}) = write(:add_image, source, options)
44
+ def add_audio(source, options = {}) = write(:add_audio, source, options)
45
+ def add_video(source, options = {}) = write(:add_video, source, options)
46
+ def enqueue_media(source, options = {}) = write(:add_media, source, options.merge(async: true))
47
+
48
+ def media_job(job_id)
49
+ return { status: 'unsupported', job_id: job_id, warnings: ['smart_rag job transport not configured'] } unless job_transport
50
+ result = job_transport.call(job_id, timeout_seconds: timeout_seconds)
51
+ result[:warnings] = Array(result[:warnings])
52
+ result
53
+ rescue Timeout::Error
54
+ { status: 'failed', job_id: job_id, warnings: ['smart_rag media_job timeout'] }
55
+ rescue StandardError => e
56
+ { status: 'failed', job_id: job_id, warnings: ["smart_rag media_job failed: #{e.message}"] }
57
+ end
58
+
59
+ def media_jobs(status: nil, limit: 20, offset: 0)
60
+ call_job_transport(job_list_transport, { status: status, limit: limit, offset: offset }, fallback: { jobs: [], total: 0 })
61
+ end
62
+
63
+ def cancel_media_job(job_id)
64
+ call_job_mutation(job_id, :cancel)
65
+ end
66
+
67
+ def retry_media_job(job_id)
68
+ call_job_mutation(job_id, :retry)
69
+ end
70
+
71
+ def media_job_statistics
72
+ call_job_transport(job_statistics_transport, fallback: { counts: {}, total: 0 })
10
73
  end
11
74
 
12
75
  def retrieve(plan)
13
76
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
- raw = transport.call(plan, timeout_seconds: timeout_seconds)
15
- build_pack(raw: raw, request_id: plan[:request_id], took_ms: elapsed_ms(started_at))
77
+ scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
78
+ return scope_failure_pack(plan, ignored, warnings, prefix: 'scope-blocked') unless scoped_plan
79
+
80
+ raw = transport.call(scoped_plan, timeout_seconds: timeout_seconds)
81
+ pack = build_pack(raw: raw, request_id: plan[:request_id], took_ms: elapsed_ms(started_at))
82
+ enforce_scope_confirmation(pack, required: required, ignored: ignored, warnings: warnings)
16
83
  rescue Timeout::Error
17
84
  {
18
85
  version: '0.1',
@@ -28,7 +95,52 @@ module SmartBrain
28
95
 
29
96
  private
30
97
 
31
- attr_reader :transport, :timeout_seconds
98
+ attr_reader :transport, :write_transport, :job_transport, :job_list_transport,
99
+ :job_mutation_transport, :job_statistics_transport, :timeout_seconds,
100
+ :scope_mapper, :fail_closed
101
+
102
+ def call_job_transport(callable, argument = nil, fallback:)
103
+ return fallback.merge(status: 'unsupported', warnings: ['smart_rag job transport not configured']) unless callable
104
+ result = argument ? callable.call(argument, timeout_seconds: timeout_seconds) : callable.call(timeout_seconds: timeout_seconds)
105
+ result[:warnings] = Array(result[:warnings])
106
+ result
107
+ rescue StandardError => e
108
+ fallback.merge(status: 'failed', warnings: ["smart_rag media job request failed: #{e.message}"])
109
+ end
110
+
111
+ def call_job_mutation(job_id, action)
112
+ return { status: 'unsupported', job_id: job_id,
113
+ warnings: ['smart_rag job transport not configured'] } unless job_mutation_transport
114
+ result = job_mutation_transport.call(job_id, action, timeout_seconds: timeout_seconds)
115
+ result[:warnings] = Array(result[:warnings])
116
+ result
117
+ rescue StandardError => e
118
+ { status: 'failed', job_id: job_id,
119
+ warnings: ["smart_rag media job request failed: #{e.message}"] }
120
+ end
121
+
122
+ def write(operation, source, options)
123
+ return unsupported_write(operation) unless write_transport
124
+
125
+ raw = write_transport.call(
126
+ { operation: operation, source: source, options: options },
127
+ timeout_seconds: timeout_seconds
128
+ )
129
+ result = raw.is_a?(Hash) ? raw.dup : {}
130
+ result[:status] ||= 'success'
131
+ result[:section_count] ||= 0
132
+ result[:warnings] = Array(result[:warnings])
133
+ result
134
+ rescue Timeout::Error
135
+ { status: 'failed', section_count: 0, warnings: ["smart_rag #{operation} timeout"] }
136
+ rescue StandardError => e
137
+ { status: 'failed', section_count: 0, warnings: ["smart_rag #{operation} failed: #{e.message}"] }
138
+ end
139
+
140
+ def unsupported_write(operation)
141
+ { status: 'unsupported', section_count: 0,
142
+ warnings: ["smart_rag write transport not configured; #{operation} ignored"] }
143
+ end
32
144
 
33
145
  def build_pack(raw:, request_id:, took_ms:)
34
146
  ignored = []
@@ -48,7 +160,8 @@ module SmartBrain
48
160
  explain: {
49
161
  ignored_fields: ignored + Array(raw.dig(:explain, :ignored_fields))
50
162
  },
51
- warnings: Array(raw[:warnings])
163
+ warnings: Array(raw[:warnings]),
164
+ scope_filter_applied: raw[:scope_filter_applied] == true
52
165
  }
53
166
  end
54
167
 
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/multipart'
5
+ require 'json'
6
+
7
+ module SmartBrain
8
+ module Adapters
9
+ module SmartRag
10
+ class HttpTransport
11
+ EXTRACTOR_OPTIONS = %i[
12
+ audio_transcriber video_transcriber image_describer frame_describer ocr_extractor
13
+ ].freeze
14
+
15
+ def initialize(base_url:, headers: {})
16
+ @connection = Faraday.new(url: base_url, headers: headers) do |faraday|
17
+ faraday.request :multipart
18
+ faraday.request :url_encoded
19
+ faraday.adapter Faraday.default_adapter
20
+ end
21
+ end
22
+
23
+ def retrieve(plan, timeout_seconds:)
24
+ response = connection.post('/v1/retrieve') do |request|
25
+ request.options.timeout = timeout_seconds
26
+ request.headers['Content-Type'] = 'application/json'
27
+ request.body = JSON.generate(plan: serializable(plan))
28
+ end
29
+ parse_response(response)
30
+ end
31
+
32
+ def write(payload, timeout_seconds:)
33
+ options = payload.fetch(:options, {})
34
+ reject_callable_options!(options)
35
+ source = payload.fetch(:source).to_s
36
+
37
+ response = if File.file?(source)
38
+ upload_file(payload[:operation], source, options, timeout_seconds)
39
+ else
40
+ write_source(payload[:operation], source, options, timeout_seconds)
41
+ end
42
+ parse_response(response)
43
+ end
44
+
45
+ def media_job(job_id, timeout_seconds:)
46
+ response = connection.get("/v1/media/jobs/#{Integer(job_id)}") do |request|
47
+ request.options.timeout = timeout_seconds
48
+ end
49
+ parse_response(response)
50
+ end
51
+
52
+ def media_jobs(params, timeout_seconds:)
53
+ response = connection.get('/v1/media/jobs', params) { |request| request.options.timeout = timeout_seconds }
54
+ parse_response(response)
55
+ end
56
+
57
+ def mutate_media_job(job_id, action, timeout_seconds:)
58
+ response = connection.post("/v1/media/jobs/#{Integer(job_id)}/#{action}") do |request|
59
+ request.options.timeout = timeout_seconds
60
+ end
61
+ parse_response(response)
62
+ end
63
+
64
+ def media_job_statistics(timeout_seconds:)
65
+ response = connection.get('/v1/media/jobs/stats') { |request| request.options.timeout = timeout_seconds }
66
+ parse_response(response)
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :connection
72
+
73
+ def upload_file(operation, source, options, timeout_seconds)
74
+ connection.post('/v1/media') do |request|
75
+ request.options.timeout = timeout_seconds
76
+ request.body = {
77
+ operation: operation.to_s,
78
+ options: JSON.generate(serializable(options)),
79
+ file: Faraday::Multipart::FilePart.new(source, mime_type(source), File.basename(source))
80
+ }
81
+ end
82
+ end
83
+
84
+ def write_source(operation, source, options, timeout_seconds)
85
+ connection.post('/v1/media') do |request|
86
+ request.options.timeout = timeout_seconds
87
+ request.headers['Content-Type'] = 'application/json'
88
+ request.body = JSON.generate(
89
+ operation: operation.to_s, source: source, options: serializable(options)
90
+ )
91
+ end
92
+ end
93
+
94
+ def reject_callable_options!(options)
95
+ unsupported = EXTRACTOR_OPTIONS.select do |key|
96
+ value = options[key] || options[key.to_s]
97
+ value.respond_to?(:call) || value.respond_to?(:extract)
98
+ end
99
+ return if unsupported.empty?
100
+
101
+ raise ArgumentError,
102
+ "HTTP media ingestion cannot serialize extractors: #{unsupported.join(', ')}; configure them on SmartRAG"
103
+ end
104
+
105
+ def serializable(value)
106
+ case value
107
+ when Hash
108
+ value.each_with_object({}) { |(key, item), result| result[key] = serializable(item) }
109
+ when Array
110
+ value.map { |item| serializable(item) }
111
+ when Symbol
112
+ value.to_s
113
+ else
114
+ value
115
+ end
116
+ end
117
+
118
+ def parse_response(response)
119
+ body = JSON.parse(response.body, symbolize_names: true)
120
+ return body if response.success?
121
+
122
+ raise "SmartRAG HTTP #{response.status}: #{body[:error] || response.reason_phrase}"
123
+ rescue JSON::ParserError
124
+ raise "SmartRAG HTTP #{response.status}: invalid JSON response"
125
+ end
126
+
127
+ def mime_type(path)
128
+ extension = File.extname(path).downcase
129
+ {
130
+ '.jpg' => 'image/jpeg', '.jpeg' => 'image/jpeg', '.png' => 'image/png',
131
+ '.wav' => 'audio/wav', '.mp3' => 'audio/mpeg', '.mp4' => 'video/mp4',
132
+ '.pdf' => 'application/pdf', '.txt' => 'text/plain', '.md' => 'text/markdown'
133
+ }.fetch(extension, 'application/octet-stream')
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end