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
data/examples/README.md
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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, :
|
|
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
|