maglev-rb 0.1.1

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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +408 -0
  4. data/README.zh-CN.md +408 -0
  5. data/lib/generators/maglev/install/install_generator.rb +70 -0
  6. data/lib/maglev/active_record_extension.rb +100 -0
  7. data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +15 -0
  8. data/lib/maglev/adapters/ruby_llm_embedding.rb +22 -0
  9. data/lib/maglev/adapters/ruby_llm_generation.rb +22 -0
  10. data/lib/maglev/adapters/ruby_llm_provider.rb +64 -0
  11. data/lib/maglev/answerer.rb +58 -0
  12. data/lib/maglev/attachment_extractor.rb +106 -0
  13. data/lib/maglev/authorization.rb +43 -0
  14. data/lib/maglev/chunk.rb +14 -0
  15. data/lib/maglev/chunker.rb +58 -0
  16. data/lib/maglev/configuration.rb +76 -0
  17. data/lib/maglev/content_source_graph.rb +66 -0
  18. data/lib/maglev/context_assembler.rb +94 -0
  19. data/lib/maglev/context_preview.rb +13 -0
  20. data/lib/maglev/dependency_graph.rb +129 -0
  21. data/lib/maglev/embedding_adapter.rb +9 -0
  22. data/lib/maglev/errors.rb +21 -0
  23. data/lib/maglev/extracted_document.rb +36 -0
  24. data/lib/maglev/generation_adapter.rb +9 -0
  25. data/lib/maglev/indexer.rb +175 -0
  26. data/lib/maglev/knowledge_config.rb +136 -0
  27. data/lib/maglev/prompt_builder.rb +27 -0
  28. data/lib/maglev/provider_call.rb +49 -0
  29. data/lib/maglev/provider_configuration.rb +18 -0
  30. data/lib/maglev/railtie.rb +19 -0
  31. data/lib/maglev/reindex_job.rb +16 -0
  32. data/lib/maglev/response.rb +26 -0
  33. data/lib/maglev/retriever.rb +89 -0
  34. data/lib/maglev/schema_compiler.rb +51 -0
  35. data/lib/maglev/search_result.rb +22 -0
  36. data/lib/maglev/snapshot.rb +14 -0
  37. data/lib/maglev/snapshot_builder.rb +204 -0
  38. data/lib/maglev/vector_stores/base.rb +31 -0
  39. data/lib/maglev/vector_stores/document.rb +39 -0
  40. data/lib/maglev/vector_stores/memory.rb +76 -0
  41. data/lib/maglev/vector_stores/pgvector.rb +94 -0
  42. data/lib/maglev/version.rb +5 -0
  43. data/lib/maglev.rb +37 -0
  44. data/lib/tasks/maglev.rake +31 -0
  45. metadata +202 -0
data/README.zh-CN.md ADDED
@@ -0,0 +1,408 @@
1
+ # Maglev
2
+
3
+ [English](README.md) | [简体中文](README.zh-CN.md)
4
+
5
+ [![CI](https://github.com/benjis/maglev/actions/workflows/ci.yml/badge.svg)](https://github.com/benjis/maglev/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-3.2%2B-CC342D.svg)](https://www.ruby-lang.org/)
7
+ [![Rails](https://img.shields.io/badge/Rails-7.1%20%7C%208.0-D30001.svg)](https://rubyonrails.org/)
8
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt)
9
+
10
+ **无需在 Rails 之外另建一套系统,也能让 Rails 模型拥有语义记忆。**
11
+
12
+ Maglev 是面向 ActiveRecord 对象图的 Rails 原生语义知识层。你只需声明领域模型中哪些信息适合被理解和检索,Maglev 就会把记录、关联关系、附件和富文本转换为可搜索的知识。它通过 Rails 原有的生命周期机制持续更新这些知识,并以熟悉的模型 API 提供语义搜索和有依据的问答能力。
13
+
14
+ ```ruby
15
+ Product.search("存在续航或易用性问题的笔记本电脑")
16
+
17
+ response = Product.ask("有哪些反复出现、值得调查的产品问题?", user: current_user)
18
+ response.text
19
+ response.sources # 支撑回答的 ActiveRecord 记录和内容分块
20
+ ```
21
+
22
+ Maglev 专注于检索增强生成(RAG)。ActiveRecord 仍然负责精确筛选、关联查询、报表和聚合;Maglev 则处理用自然语言提出的问题。
23
+
24
+ ## 为什么选择 Maglev?
25
+
26
+ - **Rails 原生:** 它是一个 gem 和 Railtie,而不是独立服务、Engine 或额外 API。
27
+ - **模型驱动:** 在拥有数据的 ActiveRecord 模型旁直接声明知识边界。
28
+ - **理解对象图:** 支持直接关联、`has_many :through` 和多态关联,并可显式限制深度与记录数。
29
+ - **自动保持新鲜:** 已声明记录、直接关联、附件或 Action Text 内容发生变化后,自动重新索引知识所有者。
30
+ - **回答有依据:** 仅根据检索到的上下文生成答案,并返回来源。
31
+ - **面向生产环境:** 内置授权接口、内容限制、清洗、重试、可观测性和幂等重建索引。
32
+ - **可扩展:** 可使用默认的 PostgreSQL/pgvector,也可实现精简的向量存储协议。
33
+
34
+ ## 快速开始
35
+
36
+ ### 1. 安装前置依赖
37
+
38
+ Maglev 需要 Ruby 3.2+、Rails 7.1 或 8.0、PostgreSQL,以及
39
+ [`pgvector`](https://github.com/pgvector/pgvector) 扩展。
40
+
41
+ 将 Maglev 加入应用:
42
+
43
+ ```ruby
44
+ # Gemfile
45
+ gem "maglev-rb"
46
+ ```
47
+
48
+ ```bash
49
+ bundle install
50
+ bin/rails generate maglev:install --embedding-dimensions=1536
51
+ bin/rails db:migrate
52
+ ```
53
+
54
+ 生成器会把 `--embedding-dimensions` 同时写入 `config/initializers/maglev.rb` 和 `maglev_chunks` 的向量列,并创建基于余弦距离的 HNSW 索引。
55
+
56
+ ### 2. 配置模型提供商
57
+
58
+ ```ruby
59
+ # config/initializers/maglev.rb
60
+ Maglev.configure do |config|
61
+ config.embedding_provider do |provider|
62
+ provider.url = "http://localhost:11434/v1"
63
+ provider.api_key = ENV["LOCAL_EMBEDDING_API_KEY"]
64
+ provider.model = "Qwen3-Embedding-0.6B-8bit"
65
+ provider.dimensions = 1024
66
+ end
67
+
68
+ config.generation_provider do |provider|
69
+ provider.url = "https://api.deepseek.com/v1"
70
+ provider.api_key = Rails.application.credentials.dig(:deepseek_api_key)
71
+ provider.model = "deepseek-chat"
72
+ end
73
+
74
+ config.chunk_size = 1000
75
+ end
76
+ ```
77
+
78
+ Embedding 与 generation endpoint 相互独立,可以使用不同的 URL、API key 和 model。默认 provider bridge 使用 OpenAI-compatible HTTP endpoint;其他协议仍可通过 Maglev 自定义 adapter 接入。
79
+
80
+ 对于已有安装,请同时修改配置维度和数据库向量列。Maglev 会在请求 embedding 前检查两者是否一致。
81
+
82
+ ### 3. 声明模型知识
83
+
84
+ ```ruby
85
+ class Product < ApplicationRecord
86
+ has_many :reviews, inverse_of: :product
87
+ has_many :product_categories, inverse_of: :product
88
+ has_many :categories, through: :product_categories
89
+ has_many_attached :images
90
+ has_rich_text :description
91
+
92
+ has_knowledge do
93
+ expose :name, :sku, :price, :status
94
+ tags :product
95
+
96
+ include_related :reviews, depth: 1, limit: 10
97
+ include_related :categories, depth: 1, limit: 10, inverse: :products
98
+
99
+ expose_attached :images
100
+ expose_rich_text :description
101
+ end
102
+ end
103
+
104
+ class Review < ApplicationRecord
105
+ belongs_to :product, inverse_of: :reviews
106
+
107
+ has_knowledge do
108
+ expose :rating, :title, :body
109
+ end
110
+ end
111
+ ```
112
+
113
+ 只有显式公开的字段和内容源才会进入 Maglev 的知识快照。关联的 `limit` 限制每个关联包含的记录数;`depth` 限制关联跳数:`depth: 1` 会包含直接关联记录,但不会继续展开该记录的关联。`config.max_relation_depth` 是每个快照从根记录到叶记录的全局硬上限。每个关联模型独立声明自己的公开知识,因此连接模型中的敏感字段不会被意外展开。
114
+
115
+ ### 4. 为已有记录建立索引
116
+
117
+ 新建和更新记录时会自动将 `Maglev::ReindexJob` 加入队列。安装后可执行一次已有数据回填:
118
+
119
+ ```bash
120
+ bin/rails maglev:reindex[Product]
121
+ # 或重建所有声明了 has_knowledge 的模型
122
+ bin/rails maglev:reindex_all
123
+ ```
124
+
125
+ 请确保生产环境中的 Active Job 后端正在运行。
126
+
127
+ ### 5. 搜索与提问
128
+
129
+ ```ruby
130
+ results = Product.search(
131
+ "存在续航或易用性问题的笔记本电脑",
132
+ limit: 10,
133
+ user: current_user
134
+ )
135
+
136
+ results.each do |result|
137
+ result.owner # => Product
138
+ result.content # 检索到的快照分块
139
+ result.source # => "snapshot"
140
+ result.distance # 余弦距离,越小越接近
141
+ result.similarity # 归一化后的便捷相似度分数
142
+ end
143
+ ```
144
+
145
+ 根据检索到的记录生成有依据的回答:
146
+
147
+ ```ruby
148
+ response = Product.ask(
149
+ "商品团队应调查哪些反复出现的产品问题?",
150
+ limit: 5,
151
+ user: current_user
152
+ )
153
+
154
+ response.text
155
+ response.sources # 所有者、分块、距离和实际检索到的内容
156
+ response.metadata
157
+ ```
158
+
159
+ 例如,回答可能根据检索到的评价内容,总结出风扇噪音、触控板偶尔失灵以及实际续航低于宣传值。应把它理解为对“已检索上下文”的总结,而不是对全部产品做出的数据库聚合;每项结论都应通过 `response.sources` 展示依据。
160
+
161
+ 实例级问题会限定在单个知识所有者内:
162
+
163
+ ```ruby
164
+ product.ask("总结这款产品被反馈的优点和缺点。", user: current_user)
165
+ ```
166
+
167
+ Rails 数据变化时,Maglev 还会沿已声明的关联更新知识。将评价移动到另一款产品后,事务提交会为原产品和新产品安排重新索引,使两边的可搜索知识保持最新:
168
+
169
+ ```ruby
170
+ review.update!(product: replacement_product)
171
+ # 两个受影响的产品都会加入 Maglev::ReindexJob 队列。
172
+ ```
173
+
174
+ 如果没有检索到可用上下文,Maglev 会返回确定性的“上下文不足”响应,而不会让模型自行猜测。
175
+
176
+ ## 工作原理
177
+
178
+ ```mermaid
179
+ flowchart LR
180
+ A["ActiveRecord 对象图"] --> B["显式知识结构"]
181
+ B --> C["有边界且已清洗的快照"]
182
+ C --> D["分块 + 嵌入"]
183
+ D --> E["向量存储<br/>默认 pgvector"]
184
+ Q["search / ask"] --> F["语义检索"]
185
+ E --> F
186
+ F --> G["上下文组装"]
187
+ G --> H["基于上下文生成"]
188
+ H --> I["回答 + 来源"]
189
+ J["Rails 提交与内容变化"] --> K["Active Job 重建索引"]
190
+ K --> C
191
+ ```
192
+
193
+ 1. `has_knowledge` 为模型及其声明的关联编译一份显式知识结构。
194
+ 2. Maglev 根据允许的属性、关联记录、附件、富文本和标签生成确定性的文本快照。
195
+ 3. 快照被拆成大小受限的分块,并通过配置的适配器生成嵌入向量。
196
+ 4. 分块被写入向量存储;默认存储使用 PostgreSQL 和 pgvector。
197
+ 5. `search` 为查询生成嵌入,并执行基于余弦距离的近邻检索。
198
+ 6. `ask` 在上下文预算内组装分块,构建有依据的提示词,并返回包含来源元数据的答案。
199
+ 7. Rails 回调会沿对象图传播已声明记录的变化,并为受影响的所有者安排重新索引。
200
+
201
+ Maglev 不会复制你的关系数据模型。向量文档会指回对应的 ActiveRecord 所有者;事务、业务规则和结构化查询仍由应用负责。
202
+
203
+ ## 知识来源
204
+
205
+ ### ActiveRecord 对象图
206
+
207
+ `include_related` 支持对普通关联、`has_many :through` 和多态关联进行有限遍历。当 Maglev 无法自动推断关联模型的变化应如何找到知识所有者时,可使用 `inverse:` 显式指定反向关联。
208
+
209
+ ```ruby
210
+ has_knowledge do
211
+ include_related :tickets, depth: 2, limit: 25
212
+ include_related :events, depth: 1, limit: 20, inverse: :eventable
213
+ end
214
+ ```
215
+
216
+ 当关联记录从一个所有者转移到另一个所有者时,Maglev 会同时重新索引旧所有者和新所有者。
217
+
218
+ 创建、删除或重新分配连接记录会改变 `has_many :through` 关系,但不会改变关联记录本身。此类连接模型变更后,请在应用中显式加入或执行所有者重新索引。
219
+
220
+ ### Active Storage 与 Action Text
221
+
222
+ ```ruby
223
+ has_knowledge do
224
+ expose_attached :contracts, :brief
225
+ expose_rich_text :notes
226
+ end
227
+ ```
228
+
229
+ HTML 和 Action Text 内容会在索引前进行清洗。附件会受到内容类型、字节大小和提取字符数限制。已声明附件和富文本的变化会触发所有者重新索引。
230
+
231
+ ### 在索引前检查内容
232
+
233
+ 以下开发者 API 可用于确认模型实际公开的内容,且不会调用嵌入或生成服务:
234
+
235
+ ```ruby
236
+ Customer.maglev_schema
237
+ customer.maglev_snapshot
238
+
239
+ preview = customer.maglev_context_preview(
240
+ question: "为什么这位客户存在风险?"
241
+ )
242
+ preview.text
243
+ preview.metadata # 包含 provider_calls: 0
244
+ ```
245
+
246
+ ## 授权
247
+
248
+ Maglev 不绑定特定的策略库。你可以配置一个简单适配器,在检索和回答时应用应用自身的授权规则:
249
+
250
+ ```ruby
251
+ class MaglevAuthorization
252
+ def scope(model:, user:)
253
+ model.accessible_by(user)
254
+ end
255
+
256
+ def authorize(record:, user:)
257
+ record.account_id == user.account_id
258
+ end
259
+ end
260
+
261
+ Maglev.configure do |config|
262
+ config.authorization_adapter = MaglevAuthorization.new
263
+ end
264
+ ```
265
+
266
+ 适配器必须实现:
267
+
268
+ - `scope(model:, user:)`:返回该用户可见的记录。
269
+ - `authorize(record:, user:)`:返回 `false` 时拒绝访问记录。
270
+
271
+ 如果未配置适配器,默认允许访问所有记录。凡是需要限定检索范围的调用,都应一致地传入 `user:`。
272
+
273
+ `customer.explain` 是面向无需用户级授权场景的便捷 API;需要用户上下文时,请使用 `customer.ask(Maglev.configuration.explain_question, user: current_user)`。授权作用域适用于默认 pgvector 检索路径。将直接 `search` 调用与自定义存储结合前,请阅读[向量存储](#向量存储)。
274
+
275
+ ## 配置
276
+
277
+ ```ruby
278
+ Maglev.configure do |config|
279
+ config.embedding_provider do |provider|
280
+ provider.url = ENV.fetch("MAGLEV_EMBEDDING_URL", "https://api.openai.com/v1")
281
+ provider.api_key = ENV["MAGLEV_EMBEDDING_API_KEY"]
282
+ provider.model = "text-embedding-3-small"
283
+ provider.dimensions = 1536
284
+ end
285
+
286
+ config.generation_provider do |provider|
287
+ provider.url = ENV.fetch("MAGLEV_GENERATION_URL", "https://api.openai.com/v1")
288
+ provider.api_key = ENV["MAGLEV_GENERATION_API_KEY"]
289
+ provider.model = "gpt-4.1-mini"
290
+ end
291
+
292
+ config.chunk_size = 1000
293
+
294
+ config.context_max_characters = 4000
295
+ config.context_per_owner_characters = 1200
296
+ config.max_relation_depth = 3
297
+
298
+ config.attachment_allowed_content_types = [
299
+ "text/plain",
300
+ "text/markdown",
301
+ "text/html"
302
+ ]
303
+ config.attachment_max_bytes = 5 * 1024 * 1024
304
+ config.attachment_max_characters = 20_000
305
+
306
+ config.provider_max_attempts = 2
307
+ config.provider_timeout = 30
308
+ config.logger = Rails.logger
309
+ end
310
+ ```
311
+
312
+ `provider_timeout` 对每次 provider 尝试分别生效。超时会被视为可重试错误,并计入 `provider_max_attempts`。
313
+
314
+ 当应用需要不同的模型提供商或策略行为时,可注入自定义的 `embedding_adapter`、`generation_adapter`、`attachment_extractor`、`authorization_adapter` 或 `source_redactor`。测试环境可以使用确定性适配器,全程不发起网络请求。
315
+
316
+ ## 向量存储
317
+
318
+ PostgreSQL 与 pgvector 是默认的生产环境方案。对于需要其他存储后端的应用,Maglev 也提供了精简的协议:
319
+
320
+ ```ruby
321
+ class MyVectorStore < Maglev::VectorStores::Base
322
+ def upsert(documents:)
323
+ # 使用 document.id 持久化或替换文档
324
+ end
325
+
326
+ def search(vector:, filters:, limit:)
327
+ # 返回最近的 Maglev::VectorStores::Document 对象
328
+ end
329
+
330
+ def delete(ids:)
331
+ # 删除具有这些稳定 ID 的文档
332
+ end
333
+
334
+ def delete_by_owner(owner_type:, owner_id:)
335
+ # 删除属于该所有者的所有文档
336
+ end
337
+
338
+ def healthcheck = :ok
339
+ def capabilities = {metadata_filtering: true}
340
+ end
341
+
342
+ Maglev.configure do |config|
343
+ config.vector_store = MyVectorStore.new
344
+ end
345
+ ```
346
+
347
+ `Maglev::VectorStores::Memory` 适合测试和本地实验。自定义存储应保留文档元数据筛选能力和稳定的文档标识语义。
348
+
349
+ 自定义向量存储目前会收到模型和所有者元数据筛选条件,但不会收到已配置的授权作用域。`ask` 仍会逐条授权检索到的所有者;使用自定义存储时,不能将直接 `search(..., user:)` 视为已按授权过滤。需要时请在自定义存储内部应用租户或策略筛选;如果搜索必须遵守授权作用域,请使用默认 pgvector 路径。
350
+
351
+ ## 运维与可观测性
352
+
353
+ ```bash
354
+ bin/rails maglev:status
355
+ bin/rails maglev:reindex[Customer]
356
+ bin/rails maglev:reindex_all
357
+ ```
358
+
359
+ 重建索引可安全重复执行:未变化的分块会被复用,过期分块会被删除。Maglev 会为索引开始/成功/失败、检索、生成和提供商重试发出 ActiveSupport 通知:
360
+
361
+ ```ruby
362
+ ActiveSupport::Notifications.subscribe(/\Amaglev\./) do |name, start, finish, id, payload|
363
+ Rails.logger.info(
364
+ event: name,
365
+ duration_ms: ((finish - start) * 1000).round(1),
366
+ **payload
367
+ )
368
+ end
369
+ ```
370
+
371
+ ## 安全与能力边界
372
+
373
+ - 应将提取内容视为不可信上下文。Maglev 会清洗支持的 HTML 来源,但授权和模型公开范围仍由应用负责。
374
+ - 只公开适合发送给所配置模型提供商的字段和内容源。
375
+ - 默认附件限制为 5 MiB 和 20,000 个提取字符;默认允许纯文本、Markdown、HTML 和 XHTML。
376
+ - v1 生成迁移使用 `bigint` 所有者 ID。使用 UUID 的模型需要自定义迁移。
377
+ - Maglev 只负责 RAG。它不会生成 SQL、通过数据库计算回答聚合问题、公开 REST 端点、提供管理界面、运行 Agent 或管理对话记忆。
378
+
379
+ ## 兼容性
380
+
381
+ CI 测试矩阵如下:
382
+
383
+ | | 支持版本 |
384
+ |---|---|
385
+ | Ruby | 3.2、3.3 |
386
+ | Rails | 7.1、8.0 |
387
+ | 数据库 | PostgreSQL + pgvector |
388
+
389
+ 矩阵之外的版本可能可以运行,但目前不作保证。
390
+
391
+ ## 开发
392
+
393
+ ```bash
394
+ bundle install
395
+
396
+ # PostgreSQL + pgvector 集成测试
397
+ MAGLEV_REQUIRE_POSTGRESQL=true MAGLEV_DATABASE=maglev_test bundle exec rspec
398
+
399
+ bundle exec standardrb
400
+ bundle exec rubocop
401
+ bundle exec rake build
402
+ ```
403
+
404
+ 默认测试套件使用确定性的虚拟适配器,不会调用真实的 LLM 或嵌入服务。
405
+
406
+ ## 许可证
407
+
408
+ Maglev 以 [MIT License](LICENSE.txt) 开源发布。
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Maglev
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+ class_option :embedding_dimensions, type: :numeric, default: 1536
10
+
11
+ def create_initializer
12
+ create_file "config/initializers/maglev.rb", <<~RUBY
13
+ # frozen_string_literal: true
14
+
15
+ Maglev.configure do |config|
16
+ config.embedding_provider do |provider|
17
+ provider.url = ENV.fetch("MAGLEV_EMBEDDING_URL", "https://api.openai.com/v1")
18
+ provider.api_key = ENV["MAGLEV_EMBEDDING_API_KEY"]
19
+ provider.model = "text-embedding-3-small"
20
+ provider.dimensions = #{options[:embedding_dimensions]}
21
+ end
22
+
23
+ config.generation_provider do |provider|
24
+ provider.url = ENV.fetch("MAGLEV_GENERATION_URL", "https://api.openai.com/v1")
25
+ provider.api_key = ENV["MAGLEV_GENERATION_API_KEY"]
26
+ provider.model = "gpt-4.1-mini"
27
+ end
28
+
29
+ config.chunk_size = 1000
30
+ end
31
+ RUBY
32
+ end
33
+
34
+ def create_migration
35
+ create_file "db/migrate/#{migration_timestamp}_create_maglev_chunks.rb", <<~RUBY
36
+ # frozen_string_literal: true
37
+
38
+ class CreateMaglevChunks < ActiveRecord::Migration[7.1]
39
+ def change
40
+ enable_extension "vector"
41
+
42
+ create_table :maglev_chunks do |t|
43
+ t.string :owner_type, null: false
44
+ t.bigint :owner_id, null: false
45
+ t.string :owner_model_name, null: false
46
+ t.string :source, null: false
47
+ t.integer :chunk_index, null: false
48
+ t.text :content, null: false
49
+ t.string :content_checksum, null: false
50
+ t.string :embedding_model, null: false
51
+ t.vector :embedding, limit: #{options[:embedding_dimensions]}, null: false
52
+ t.timestamps
53
+ end
54
+
55
+ add_index :maglev_chunks, [:owner_type, :owner_id, :source, :chunk_index], unique: true, name: "index_maglev_chunks_on_owner_source_chunk"
56
+ add_index :maglev_chunks, :owner_model_name
57
+ add_index :maglev_chunks, :embedding, using: :hnsw, opclass: :vector_cosine_ops
58
+ end
59
+ end
60
+ RUBY
61
+ end
62
+
63
+ private
64
+
65
+ def migration_timestamp
66
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ require_relative "answerer"
6
+ require_relative "content_source_graph"
7
+ require_relative "context_preview"
8
+ require_relative "dependency_graph"
9
+ require_relative "knowledge_config"
10
+ require_relative "indexer"
11
+ require_relative "reindex_job"
12
+ require_relative "retriever"
13
+ require_relative "schema_compiler"
14
+ require_relative "snapshot_builder"
15
+
16
+ module Maglev
17
+ module ActiveRecordExtension
18
+ extend ActiveSupport::Concern
19
+
20
+ class_methods do
21
+ def has_knowledge(&block)
22
+ @maglev_config = KnowledgeConfig.build(self, &block)
23
+ @maglev_schema = SchemaCompiler.new(@maglev_config).compile if @maglev_config.relations.any?
24
+ DependencyGraph.register(@maglev_schema) if @maglev_schema
25
+ ContentSourceGraph.register(@maglev_config)
26
+ register_maglev_callbacks
27
+ end
28
+
29
+ def maglev_config
30
+ if instance_variable_defined?(:@maglev_config)
31
+ @maglev_config
32
+ elsif superclass.respond_to?(:maglev_config)
33
+ superclass.maglev_config
34
+ end
35
+ end
36
+
37
+ def search(query, limit: 10, user: nil)
38
+ Retriever.new(self).search(query, limit: limit, user: user)
39
+ end
40
+
41
+ def maglev_schema
42
+ config = maglev_config
43
+ {
44
+ model: name,
45
+ exposed_attributes: config&.exposed_attributes || [],
46
+ relations: config&.relations&.map(&:name) || [],
47
+ attached_sources: config&.attached_sources&.map(&:name) || [],
48
+ rich_text_sources: config&.rich_text_sources&.map(&:name) || []
49
+ }
50
+ end
51
+
52
+ def ask(question, limit: 10, user: nil)
53
+ user ? Answerer.new(self).ask(question, limit: limit, user: user) : Answerer.new(self).ask(question, limit: limit)
54
+ end
55
+
56
+ private
57
+
58
+ def register_maglev_callbacks
59
+ return if instance_variable_defined?(:@maglev_callbacks_registered) && @maglev_callbacks_registered
60
+
61
+ after_commit :maglev_reindex, on: %i[create update]
62
+ after_commit :maglev_unindex, on: :destroy
63
+ @maglev_callbacks_registered = true
64
+ end
65
+ end
66
+
67
+ def maglev_snapshot
68
+ SnapshotBuilder.new(self, self.class.maglev_config).build.to_s
69
+ end
70
+
71
+ def ask(question, limit: 10, user: nil)
72
+ if user
73
+ Answerer.new(self.class).ask(question, limit: limit, owner: self, user: user)
74
+ else
75
+ Answerer.new(self.class).ask(question, limit: limit, owner: self)
76
+ end
77
+ end
78
+
79
+ def explain(limit: 10)
80
+ ask(Maglev.configuration.explain_question, limit: limit)
81
+ end
82
+
83
+ def maglev_context_preview(question: nil)
84
+ ContextPreview.new(
85
+ text: maglev_snapshot,
86
+ metadata: {question: question, provider_calls: 0}
87
+ )
88
+ end
89
+
90
+ private
91
+
92
+ def maglev_reindex
93
+ ReindexJob.perform_later(self.class.name, id)
94
+ end
95
+
96
+ def maglev_unindex
97
+ Indexer.new(self).unindex
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../attachment_extractor"
4
+
5
+ module Maglev
6
+ module Adapters
7
+ class RubyLLMAttachmentExtractor < AttachmentExtractor
8
+ def extract(blob, source_name:)
9
+ require "ruby_llm"
10
+
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../embedding_adapter"
4
+ require_relative "../provider_configuration"
5
+ require_relative "ruby_llm_provider"
6
+
7
+ module Maglev
8
+ module Adapters
9
+ class RubyLLMEmbedding < EmbeddingAdapter
10
+ def initialize(provider: Maglev.configuration.embedding_provider, model: nil, dimensions: nil)
11
+ configuration = provider.to_h
12
+ configuration[:model] = model if model
13
+ configuration[:dimensions] = dimensions if dimensions
14
+ @client = RubyLLMProvider.new(ProviderConfiguration.new(**configuration))
15
+ end
16
+
17
+ def embed(text)
18
+ @client.embed(text)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../configuration"
4
+ require_relative "../generation_adapter"
5
+ require_relative "../provider_configuration"
6
+ require_relative "ruby_llm_provider"
7
+
8
+ module Maglev
9
+ module Adapters
10
+ class RubyLLMGeneration < GenerationAdapter
11
+ def initialize(provider: Maglev.configuration.generation_provider, model: nil)
12
+ configuration = provider.to_h
13
+ configuration[:model] = model if model
14
+ @client = RubyLLMProvider.new(ProviderConfiguration.new(**configuration))
15
+ end
16
+
17
+ def generate(prompt)
18
+ @client.generate(prompt)
19
+ end
20
+ end
21
+ end
22
+ end