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,271 @@
1
+ # 媒体记忆:契约与数据模型设计(阶段 0 定稿)
2
+
3
+ > 状态:**P0-P3、数据完整性修复和真实端到端验证已实现**(2026-08-14)
4
+ > 范围:smart_brain 在 SmartRAG 文本资源记忆之外,扩展 image / audio / video 等媒体记忆的契约与数据模型。
5
+ > 本文档是阶段 1–4 实现的对照依据。
6
+
7
+ ## 1. 设计目标与原则
8
+
9
+ 1. **载体复用,不加表**:媒体记忆 = 结构化元数据(存 `source_documents.metadata` JSONB)+ 可检索文本(存 `source_sections`)。现有文本链路零改动。
10
+ 2. **两个正交维度**:`source_type`(来源:url/file/manual)与 `media_type`(内容类别:image/audio/video/document/other)互不干扰,不能合并。
11
+ 3. **向后兼容**:`add_document` 行为不变;`retrieve` 无 `media_type` 过滤时行为不变;证据结构字段只增不改。
12
+ 4. **可降级**:LLM/whisper 依赖缺失时,仅存技术元数据 + 文件名/标签,仍有价值且不报错。
13
+
14
+ ## 2. 媒体类型清单(`media_type` 枚举)
15
+
16
+ | 值 | 含义 | 覆盖内容 | 专用入口 |
17
+ |---|---|---|---|
18
+ | `image` | 图片 | jpg/png/gif/webp/heic | `add_image` |
19
+ | `audio` | 音频 | mp3/wav/m4a/flac/ogg | `add_audio` |
20
+ | `video` | 视频 | mp4/mkv/mov/webm | `add_video` |
21
+ | `document` | 文档类 | pdf/docx/xlsx/pptx/md/txt/html/csv/json 等(即现有 `add_document` 全部覆盖面,**text 并入 document**) | `add_document` |
22
+ | `other` | 无法识别 | — | `add_media` 兜底 |
23
+
24
+ - 枚举值:`image / audio / video / document / other`。
25
+ - `add_document` 语义不变,仅显式归类为 `media_type: "document"`。
26
+ - 命名以产品语义为准,不与 IANA 的 `application` 对齐。
27
+
28
+ ### 2.1 类型推断规则(`add_media(media_type: :auto)` 时)
29
+
30
+ 1. 扩展名映射表优先(`.jpg/.png` → image,`.mp3/.wav` → audio,`.mp4/.mov` → video,其余文档扩展名 → document);
31
+ 2. 无法从扩展名判断时用 `file` 命令 MIME 兜底;
32
+ 3. 仍无法识别 → `other`。
33
+
34
+ ## 3. 数据模型:`metadata` JSONB schema
35
+
36
+ 在现有 `extract_metadata` 产物(`file_path/file_size/file_type/created_at/modified_at` + 用户覆盖)之上,新增三个键:
37
+
38
+ ```json
39
+ {
40
+ "media_type": "image",
41
+ "media": {
42
+ "format": "jpeg",
43
+ "width": 1920,
44
+ "height": 1080,
45
+ "dpi": 72,
46
+ "color_mode": "RGB",
47
+ "orientation": "landscape",
48
+ "exif": { "datetime": "2026-07-01T10:00:00Z", "camera_model": "X", "gps": { "lat": 31.2, "lon": 121.5 } },
49
+ "duration_ms": null,
50
+ "bitrate_bps": null,
51
+ "sample_rate_hz": null,
52
+ "channels": null,
53
+ "codec": null,
54
+ "fps": null,
55
+ "audio_codec": null
56
+ },
57
+ "semantic": {
58
+ "description": "产品发布会现场白板照片",
59
+ "captions": ["画面中央是需求流程图,标题为 Q3 路线图"],
60
+ "tags": ["发布会", "白板", "路线图"]
61
+ },
62
+ "content": "(截断后的转写/描述摘要,见 3.3)"
63
+ }
64
+ ```
65
+
66
+ ### 3.1 设计要点(已确认决策)
67
+
68
+ - **D1(通过)**:`media` 是**单一对象、三态共用、类型特有字段置 null**。检索与展示代码无需按类型分支。
69
+ - **D2(通过)**:`media_type` 在**顶层冗余**一份,`metadata->>'media_type'` 可直接查询/过滤,不用剥 `media` 对象。
70
+ - **D3(通过)**:`title` **只进 `document.title` 列**,不进 `semantic`,避免双写不一致。
71
+ - **D4(通过)**:`metadata.content` 存**截断后的转写/描述摘要**,全文只进 `sections`。
72
+
73
+ ### 3.2 `media` 字段说明
74
+
75
+ | 字段 | 适用 | 说明 |
76
+ |---|---|---|
77
+ | `format` | 全部 | 实际格式(jpeg/mp3/mp4/…),来自解析 |
78
+ | `width` / `height` | image/video | 像素尺寸;其他类型 null |
79
+ | `dpi` | image | 分辨率;其他类型 null |
80
+ | `color_mode` | image | RGB/CMYK/灰度等 |
81
+ | `orientation` | image/video | landscape/portrait,可推导可不存 |
82
+ | `exif` | image | 可选:拍摄时间、相机、GPS;无则 `{}` |
83
+ | `duration_ms` | audio/video | 时长(毫秒) |
84
+ | `bitrate_bps` | audio/video | 码率(bps),解析不到则 null |
85
+ | `sample_rate_hz` | audio | 采样率 |
86
+ | `channels` | audio | 声道数 |
87
+ | `codec` | audio/video | 编码(h264/mp3/…) |
88
+ | `fps` | video | 帧率 |
89
+ | `audio_codec` | video | 视频内音轨编码 |
90
+
91
+ ### 3.3 `semantic` 字段说明
92
+
93
+ - `description`:用户或 LLM 提供的整体描述(可 null)。
94
+ - `captions`:LLM 生成的描述/字幕句(数组,可空)。图片为画面描述,音频/视频为转写字幕句。
95
+ - `tags`:用户标签(数组,可空)。
96
+
97
+ ## 4. 写侧 API 契约
98
+
99
+ ### 4.1 SmartRAG 层(真实执行者)
100
+
101
+ ```ruby
102
+ rag.add_image(source, options = {}) # media_type 固定 "image"
103
+ rag.add_audio(source, options = {}) # 固定 "audio"
104
+ rag.add_video(source, options = {}) # 固定 "video"
105
+ rag.add_document(source, options = {}) # 现有,显式归类 media_type: "document"
106
+ rag.add_media(source, options = {}) # media_type: :auto | "image" | "audio" | "video" | "document"
107
+ ```
108
+
109
+ `options` 统一字典(全可选,命名对齐现有 `add_document` 的 options):
110
+
111
+ - `title:` / `description:` / `author:` / `tags:` → 语义元数据
112
+ - `media_type:` → 仅 `add_media` 使用
113
+ - `llm_caption: true` → 图片/视频帧生成描述(默认开,依赖缺失降级)
114
+ - `transcribe: true` → 音频/视频转写(默认开,依赖缺失降级)
115
+ - `generate_embeddings:` / `generate_tags:` → 透传现有 `save_sections` 选项
116
+ - `metadata: {}` → 用户覆盖(现有约定:merge 进 extract_metadata 结果)
117
+ - `source_type:` / `source_uri:` / `url:` → 透传现有 `create_or_update_document`
118
+
119
+ 统一返回结构:
120
+
121
+ ```ruby
122
+ {
123
+ document_id: 123,
124
+ media_type: "image",
125
+ status: "success", # "success" | "unsupported"
126
+ section_count: 3,
127
+ metadata: { media: {...}, semantic: {...} }, # 入库后回读的元数据
128
+ warnings: ["whisper 未安装,跳过音频转写"] # 降级说明
129
+ }
130
+ ```
131
+
132
+ ### 4.2 smart_brain 适配层(三个客户端统一契约)
133
+
134
+ ```ruby
135
+ add_document(source, options = {}) # 新增(现有客户端只有 retrieve)
136
+ add_image(source, options = {})
137
+ add_audio(source, options = {})
138
+ add_video(source, options = {})
139
+ add_media(source, options = {})
140
+ ```
141
+
142
+ - `DirectClient` → 转发 `@rag`
143
+ - `HttpClient` → `POST /v1/media`;本地文件走 multipart,URL 走 JSON。服务端 extractor 由 SmartRAG `HttpApp` 注入。
144
+ - `NullClient` → `{status: "unsupported", warnings: ["smart_rag client not configured; add_media ignored"]}`(fail closed,与现有 retrieve 降级风格一致)
145
+
146
+ ### 4.3 smart_brain 门面(阶段 4 薄封装)
147
+
148
+ ```ruby
149
+ SmartBrain.add_image(source:, options: {})
150
+ SmartBrain.add_audio(source:, options: {})
151
+ SmartBrain.add_video(source:, options: {})
152
+ SmartBrain.add_media(source:, options: {})
153
+ ```
154
+
155
+ - **不绑定 session**:媒体资源是全局资源记忆(归 SmartRAG 库),不属于某个对话会话。
156
+ - 导入动作是否记入 EventStore → 列为后续增强点,不纳入本契约。
157
+
158
+ ## 5. 读侧 / 检索契约
159
+
160
+ ### 5.1 过滤(RetrievalPlan → SmartRAG filters)
161
+
162
+ ```ruby
163
+ plan[:filters] = { media_type: "image" } # 或 ["image", "audio"]
164
+ ```
165
+
166
+ - 单值或数组皆可(对齐现有 `source_type` 的 `Array()` 归一化风格)。
167
+ - 实现落在 `candidate_passes_filters?` 新增分支:从 `candidate[:metadata][:media_type]` 取值比较。
168
+ - 不带此过滤时行为不变。
169
+
170
+ ### 5.2 证据透传(EvidencePack)
171
+
172
+ - **不新增 `kind`**:仍是 `resource_section`(媒体检索命中的就是转写/描述文本块)。
173
+ - `metadata` 透传 `media_type` + `media` + `semantic`,`source_type`/`title`/`snippet` 字段语义不变。
174
+ - 现有 merger/composer 消费代码无需改动(它们只读 title/snippet/metadata)。
175
+
176
+ ## 6. 完整示例(评审用)
177
+
178
+ ### 6.1 image
179
+
180
+ ```json
181
+ {
182
+ "media_type": "image",
183
+ "media": { "format": "jpeg", "width": 1920, "height": 1080, "dpi": 72, "color_mode": "RGB",
184
+ "orientation": "landscape", "exif": {}, "duration_ms": null, "codec": null,
185
+ "bitrate_bps": null, "sample_rate_hz": null, "channels": null, "fps": null, "audio_codec": null },
186
+ "semantic": { "description": "产品发布会白板", "captions": ["画面中央是需求流程图,标题为 Q3 路线图"], "tags": [] },
187
+ "content": "需求流程图:Q3 路线图……(LLM 描述摘要)"
188
+ }
189
+ ```
190
+
191
+ ### 6.2 audio
192
+
193
+ ```json
194
+ {
195
+ "media_type": "audio",
196
+ "media": { "format": "mp3", "duration_ms": 1870000, "bitrate_bps": 128000, "sample_rate_hz": 44100,
197
+ "channels": 2, "codec": "mp3", "width": null, "height": null, "dpi": null,
198
+ "color_mode": null, "orientation": null, "exif": {}, "fps": null, "audio_codec": null },
199
+ "semantic": { "description": null, "captions": ["……转写句子 1", "……转写句子 2"], "tags": ["会议"] },
200
+ "content": "大家好,今天我们讨论 Q3 路线图……(转写摘要)"
201
+ }
202
+ ```
203
+
204
+ ### 6.3 video
205
+
206
+ ```json
207
+ {
208
+ "media_type": "video",
209
+ "media": { "format": "mp4", "duration_ms": 452000, "width": 1920, "height": 1080, "fps": 30,
210
+ "codec": "h264", "audio_codec": "aac", "bitrate_bps": null, "sample_rate_hz": null,
211
+ "channels": null, "dpi": null, "color_mode": null, "orientation": "landscape",
212
+ "exif": {} },
213
+ "semantic": { "description": null, "captions": ["第 1 帧:界面加载中……", "字幕:点击导入按钮"], "tags": [] },
214
+ "content": "字幕与帧描述摘要……"
215
+ }
216
+ ```
217
+
218
+ ## 7. 存储影响
219
+
220
+ - 文档级媒体信息继续使用 `source_documents.metadata` JSONB。
221
+ - 第二批 MVP 为 `source_sections` 增加 `metadata` JSONB + GIN,用于保存视频片段的 `start_ms/end_ms/frame_timestamp_ms/extraction_kind`。
222
+ - 可选优化(不阻塞):如需高频 `media_type` 过滤可加表达式索引,但 JSONB GIN 对 `@>` 与 key 路径已覆盖,**建议先不加**。
223
+
224
+ ## 8. 边界 / 非目标(明确不做)
225
+
226
+ - 不新建表、不改现有文本链路、不新增 `source_type` 值(如 `"media"`)——`source_type` 语义保持。
227
+ - 不做视频语义理解(仅字幕/帧描述/OCR)。
228
+ - 不做对象存储/大文件托管(文件仍走本地路径/URL)。
229
+ - 初始 MVP 不做权限/配额控制;P3 已补充 Bearer principal、任务/文档隔离和数据库配额。
230
+ - 不把导入动作记入 EventStore(列为后续增强)。
231
+
232
+ ## 9. 决策点结论(已确认)
233
+
234
+ | 决策点 | 结论 |
235
+ |---|---|
236
+ | D1 media 对象形态 | 单一对象 + null 填充(通过) |
237
+ | D2 media_type 顶层冗余 | 冗余(通过) |
238
+ | D3 title 归属 | 只进 `document.title` 列,不进 semantic(通过) |
239
+ | D4 metadata.content | 存截断摘要,全文进 sections(通过) |
240
+ | D5 llm_caption / transcribe 默认 | 默认开,缺依赖降级 + warnings(通过) |
241
+ | D6 文档类枚举值 | `document`(非 application),`add_document` 即专用入口(通过) |
242
+ | D6a text 归类 | 并入 `document`(通过) |
243
+ | D7 smart_brain 门面 | 阶段 4 做薄封装(通过) |
244
+ | D8 枚举命名 | 产品语义为准,不与 IANA 对齐(通过) |
245
+ | D9 filters[:media_type] | 单值 + 数组(通过) |
246
+
247
+ ## 10. 后续阶段对照
248
+
249
+ - **阶段 1**:SmartRAG `MediaMetadataExtractor`(image/audio/video 技术元数据解析,Python bridge,可降级)。
250
+ - **阶段 2**:媒体内容可检索化(图片描述/OCR、音频转写、视频音轨转写和关键帧描述已支持通过 extractor callable 接入)。
251
+ - **阶段 3**:SmartRAG `add_image/add_audio/add_video/add_media` API(复用 `create_document` 管线)。
252
+ - **阶段 4**:smart_brain 适配层写 API(三客户端)+ HTTP JSON/multipart 协议 + `filters[:media_type]` 检索过滤 + 门面封装(已完成)。
253
+ - **阶段 5**:测试、文档、示例(`examples/media_memory.rb`)。
254
+
255
+ ## 11. P1 / P2 生产能力补充
256
+
257
+ - **P1**:内置 OCR/视觉/转写适配器、时间戳切片、场景检测、安全限制、本地内容寻址存储、PostgreSQL 异步队列和 worker。
258
+ - **P2**:任务分页与状态过滤、取消与人工重试、过期 `processing` 恢复、终态保留期清理、队列统计及健康检查。失败的异步上传在清理期内保留,以支持人工重试。
259
+ - `media_jobs` 由迁移 013 创建,迁移 014 增加恢复和清理所需的组合索引。
260
+ - **P3**:迁移 015 增加 heartbeat lease、按 principal 隔离的幂等键、`media_objects` / `media_object_references` 引用管理和数据库配额计数。内容存储支持本地 CAS 与可选 S3/MinIO;HTTP 可启用 Bearer 认证和调用方配额。
261
+ - **数据完整性修复**:迁移 016 为文档增加 `principal` owner,并用 `media_jobs.staging_media_object_id` 显式保护 queued/processing/failed 任务的源对象。同步导入在语义提取前登记零引用对象,失败后可由 GC 回收;检索、读取、列表、删除和统计按认证 principal 隔离。
262
+ - **幂等冲突检测**:迁移 017 为任务增加非空 `request_fingerprint`,并为历史任务回填 SHA-256 指纹。指纹由 operation、source 和递归规范化后的可序列化 options 组成;同 principal/幂等键/指纹返回原任务,同 principal/幂等键但指纹不同则返回 HTTP 409 `idempotency_conflict`。不同 principal 可复用同一键。
263
+ - **检索纵深隔离**:认证 principal 先转换为 PostgreSQL 中拥有的 document ID 白名单并下推搜索,再对后端返回候选逐条复核 owner。HTTP Bearer 认证失败返回 401,检索结果不公开内部 principal 过滤字段。
264
+ - **真实 MinIO E2E**:显式启用的集成规格验证两个独立 S3 client 实例间的字节一致性、异步 worker 对 `s3://` URI 的物化、引用计数、解除引用后的真实删除,以及 retained failed job 对 staging 对象的 GC 保护。
265
+
266
+ ### 11.1 升级和调用方约束
267
+
268
+ 1. 部署新队列代码前,先将 SmartRAG 数据库迁移到 017;新列为 `NOT NULL`,绕过队列直接写 `media_jobs` 的内部工具也必须写入有效指纹。
269
+ 2. 网络重试必须复用原幂等键和完全相同的业务载荷。若 source、operation 或 options 改变,应创建新幂等键,不能把 409 当作成功去重。
270
+ 3. SmartBrain `HttpClient` 将 SmartRAG HTTP 409 包装为 `status: "failed"` 和 warning;`DirectClient` 直接传播 `MediaJobQueue::IdempotencyConflict`。业务层应分别检查结果状态或捕获异常。
271
+ 4. MinIO/S3 多实例部署必须使用所有 worker 均可访问的 endpoint、bucket 和 prefix。任务保留期结束并被 prune 后,零引用 staging 对象才允许被 GC。
@@ -110,6 +110,7 @@ RetrievalPlan 是一个 JSON/Ruby Hash 对象(可序列化入日志)。
110
110
  "tag_ids": ["..."],
111
111
  "topic_ids": ["..."],
112
112
  "source_type": ["url","file","manual","memory_snapshot"],
113
+ "media_type": ["image","audio","video","document","other"],
113
114
  "source_uri_prefix": ["https://...", "file://..."],
114
115
  "language": ["zh","en"],
115
116
  "time_range": { "from": "2026-01-01T00:00:00Z", "to": "2026-02-20T00:00:00Z" }
@@ -120,6 +121,7 @@ RetrievalPlan 是一个 JSON/Ruby Hash 对象(可序列化入日志)。
120
121
 
121
122
  * `document_ids/tag_ids/topic_ids`:知识库内过滤
122
123
  * `source_type`:资源类型过滤(建议 SmartRAG 支持)
124
+ * `media_type`:媒体内容类型过滤;接受单值或数组
123
125
  * `source_uri_prefix`:按 URI 前缀过滤(适合域名/路径范围)
124
126
  * `language`:语言过滤(可选)
125
127
  * `time_range`:时间过滤(可按 documents.created_at 或 sections.created_at 实现)
@@ -228,4 +230,4 @@ RetrievalPlan 是一个 JSON/Ruby Hash 对象(可序列化入日志)。
228
230
 
229
231
  * v0.1:新增字段只能“可选”,不得破坏现有字段含义
230
232
  * v0.x:执行方必须忽略未知字段并在 explain 中声明(可选)
231
- * v1.0:如需破坏性变更,另起 major 版本并提供迁移说明
233
+ * v1.0:如需破坏性变更,另起 major 版本并提供迁移说明
data/docs/user_guide.md CHANGED
@@ -539,6 +539,7 @@ SMARTBRAIN_LLM=1 bundle exec rspec spec/smoke_ollama_spec.rb # 真实 Ollama
539
539
 
540
540
  ## 18. 进阶文档
541
541
 
542
+ - 安装与配置(最终用户):`docs/installation.md`
542
543
  - 设计与契约:`docs/smartbrain_design.md`、`docs/policies.md`、`docs/memory_types.md`
543
544
  - 策略与治理:`docs/retrieval_plan.md`、`docs/evidence_pack.md`、`docs/context_package.md`
544
545
  - MCP 接入:`docs/mcp.md`
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'smart_brain'
4
+ require 'smart_rag'
5
+ require 'smart_brain/adapters/smart_rag/direct_client'
6
+
7
+ rag = SmartRAG::SmartRAG.new(
8
+ database: {
9
+ adapter: 'postgresql',
10
+ host: ENV.fetch('SMARTBRAIN_RAG_DB_HOST', '127.0.0.1'),
11
+ port: ENV.fetch('SMARTBRAIN_RAG_DB_PORT', '5432').to_i,
12
+ database: ENV.fetch('SMARTBRAIN_RAG_DB_NAME', 'smart_rag_development'),
13
+ user: ENV.fetch('SMARTBRAIN_RAG_DB_USER', 'rag_user'),
14
+ password: ENV.fetch('SMARTBRAIN_RAG_DB_PASSWORD', 'rag_pwd')
15
+ }
16
+ )
17
+ SmartBrain.configure(
18
+ smart_rag_client: SmartBrain::Adapters::SmartRag::DirectClient.new(rag: rag)
19
+ )
20
+
21
+ image_result = SmartBrain.add_image(
22
+ source: ARGV.fetch(0),
23
+ options: {
24
+ title: 'Imported image',
25
+ tags: ['image'],
26
+ # Replace this callable with the vision/OCR provider used by your app.
27
+ image_describer: ->(_path) { 'A user-provided image description' }
28
+ }
29
+ )
30
+ puts image_result
31
+
32
+ if ARGV[1]
33
+ video_result = SmartBrain.add_video(
34
+ source: ARGV[1],
35
+ options: {
36
+ title: 'Imported video',
37
+ video_transcriber: lambda do |_audio_path|
38
+ { segments: [{ text: 'Example timestamped transcript', start: 0.0, end: 5.0 }] }
39
+ end,
40
+ frame_describer: ->(_frame_path, timestamp_ms) { "Video frame at #{timestamp_ms} ms" },
41
+ frame_interval_seconds: 30,
42
+ max_frames: 6
43
+ }
44
+ )
45
+ puts video_result
46
+ end
47
+
48
+ context = SmartBrain.compose_context(
49
+ session_id: 'media-demo',
50
+ user_message: '查资料:这张图片里有什么?',
51
+ resource_filters: { media_type: 'image' }
52
+ )
53
+ puts context[:evidence]
@@ -34,10 +34,51 @@ module SmartBrain
34
34
  }
35
35
  end
36
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
+
37
58
  private
38
59
 
39
60
  attr_reader :rag, :scope_mapper, :fail_closed
40
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
81
+
41
82
  def normalize_pack(response, request_id:)
42
83
  pack = response.is_a?(Hash) ? response : {}
43
84
  {
@@ -8,13 +8,70 @@ module SmartBrain
8
8
  class HttpClient
9
9
  include ScopeFilter
10
10
 
11
- def initialize(transport:, timeout_seconds: 2, scope_mapper: nil, fail_closed: true)
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)
12
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
13
20
  @timeout_seconds = timeout_seconds
14
21
  @scope_mapper = scope_mapper
15
22
  @fail_closed = fail_closed
16
23
  end
17
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 })
73
+ end
74
+
18
75
  def retrieve(plan)
19
76
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
77
  scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
@@ -38,7 +95,52 @@ module SmartBrain
38
95
 
39
96
  private
40
97
 
41
- attr_reader :transport, :timeout_seconds, :scope_mapper, :fail_closed
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
42
144
 
43
145
  def build_pack(raw:, request_id:, took_ms:)
44
146
  ignored = []
@@ -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