ruby_llm 2.0.0.rc2 → 2.0.0.rc3
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/README.md +2 -2
- data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +34 -0
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +332 -0
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +171 -0
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +197 -0
- data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +170 -0
- data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +14 -11
- data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +6 -6
- data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +31 -5
- data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +16 -14
- data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +36 -7
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +15 -5
- data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +14 -0
- data/lib/ruby_llm/accounting/usage.rb +9 -0
- data/lib/ruby_llm/agent.rb +10 -9
- data/lib/ruby_llm/aliases.json +26 -4
- data/lib/ruby_llm/attachment.rb +5 -0
- data/lib/ruby_llm/chat.rb +4 -0
- data/lib/ruby_llm/message.rb +14 -5
- data/lib/ruby_llm/models.json +3820 -1346
- data/lib/ruby_llm/protocols/bedrock/async_videos.rb +2 -1
- data/lib/ruby_llm/protocols/chat_completions/rerank.rb +8 -1
- data/lib/ruby_llm/protocols/cohere/rerank.rb +8 -1
- data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +5 -0
- data/lib/ruby_llm/protocols/interactions/tools.rb +3 -1
- data/lib/ruby_llm/providers/deepseek/responses.rb +0 -1
- data/lib/ruby_llm/providers/mistral/ocr.rb +5 -1
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/tasks/ruby_llm.rake +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf1e2bc13df637c93268a6ee20e52c704c47f942be416c4d6728882ae2cc35bf
|
|
4
|
+
data.tar.gz: b63c7e4eef810843ad1bcb91a8ad55413f90764ba7e984d310c8a6b4bec3bec0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 030126750af8dea0303a81093d415e361fdfb7335620d8e84da3fd024acba2e5323bb7fcbd47530a6a3d5cbc24ba3c2f771db431aabd8011502bd19b9b68aa71
|
|
7
|
+
data.tar.gz: c783cfc4c121db83f4e05867893b7b822ffeddb53010eb22a5053d37eca7a539ee26dc6181b669ac5b2fac856464a18cc8929303127c1e5ae1f1fe28f9c1f373
|
data/README.md
CHANGED
|
@@ -38,7 +38,7 @@ In Rails, the API works on your own Chat and Message records, with Active Storag
|
|
|
38
38
|
|
|
39
39
|
## Show me the code
|
|
40
40
|
|
|
41
|
-
These examples use **2.0.0.
|
|
41
|
+
These examples use **2.0.0.rc3**. Follow [Getting Started](https://rubyllm.com/next/getting-started/) to install it and configure the providers you want to try.
|
|
42
42
|
|
|
43
43
|
```ruby
|
|
44
44
|
# Just ask questions
|
|
@@ -195,7 +195,7 @@ response.parsed
|
|
|
195
195
|
Install the 2.0 release candidate:
|
|
196
196
|
|
|
197
197
|
```bash
|
|
198
|
-
bundle add ruby_llm --version 2.0.0.
|
|
198
|
+
bundle add ruby_llm --version 2.0.0.rc3
|
|
199
199
|
```
|
|
200
200
|
|
|
201
201
|
Configure a provider in your script, or in `config/initializers/ruby_llm.rb` in Rails:
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Generators
|
|
5
|
+
class LegacyContentSQL # :nodoc: all
|
|
6
|
+
SPACE = [9, 10, 11, 12, 13, 32, 133, 160, 5760, *8192..8202, 8232, 8233, 8239, 8287, 12_288].pack('U*').freeze
|
|
7
|
+
|
|
8
|
+
def initialize(connection)
|
|
9
|
+
@connection = connection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(content:, raw:)
|
|
13
|
+
normalized, rendered, nonblank = expressions(raw)
|
|
14
|
+
present = "#{normalized} NOT IN ('null', 'false', '[]', '{}') AND #{nonblank}"
|
|
15
|
+
"CASE WHEN #{present} THEN #{rendered} ELSE #{content} END"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def expressions(raw)
|
|
21
|
+
case @connection.adapter_name
|
|
22
|
+
when 'PostgreSQL'
|
|
23
|
+
["(#{raw}::jsonb)::text", "#{raw}::text",
|
|
24
|
+
"btrim(#{raw}::jsonb #>> '{}', #{@connection.quote(SPACE)}) <> ''"]
|
|
25
|
+
when 'Mysql2'
|
|
26
|
+
["CAST(#{raw} AS CHAR)", "CAST(#{raw} AS CHAR)",
|
|
27
|
+
"JSON_UNQUOTE(#{raw}) NOT REGEXP '^[[:space:]]*$'"]
|
|
28
|
+
else
|
|
29
|
+
["json(#{raw})", raw, "trim(json_extract(#{raw}, '$'), #{@connection.quote(SPACE)}) <> ''"]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../legacy_content_sql'
|
|
5
|
+
require_relative 'verification'
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Generators
|
|
9
|
+
class OnlineCopyMigration < UpgradeMigration
|
|
10
|
+
class Data < UpgradeMigration # :nodoc: all
|
|
11
|
+
BATCH_SIZE = 10_000
|
|
12
|
+
PROGRESS = :ruby_llm_v2_backfills
|
|
13
|
+
TASKS = %w[message_content tool_results usages].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(connection:, settings:)
|
|
16
|
+
super(connection:)
|
|
17
|
+
@configuration = settings
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def prepare
|
|
21
|
+
messages = configuration.fetch('message_table')
|
|
22
|
+
unless @connection.column_exists?(messages, :ruby_llm_content)
|
|
23
|
+
@connection.add_column(messages, :ruby_llm_content, :text)
|
|
24
|
+
end
|
|
25
|
+
%i[ruby_llm_tool_calls ruby_llm_usages].each do |table|
|
|
26
|
+
@connection.add_column(table, :legacy_key, :string) unless @connection.column_exists?(table, :legacy_key)
|
|
27
|
+
unless @connection.index_exists?(table, :legacy_key, unique: true)
|
|
28
|
+
@connection.add_index(table, :legacy_key, unique: true)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
return if @connection.column_exists?(:ruby_llm_tool_calls, :legacy_chat_id)
|
|
32
|
+
|
|
33
|
+
@connection.add_column(:ruby_llm_tool_calls, :legacy_chat_id, :string)
|
|
34
|
+
@connection.add_index(:ruby_llm_tool_calls, :legacy_chat_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def backfill
|
|
38
|
+
prepare_progress
|
|
39
|
+
sync_models
|
|
40
|
+
sync_chat_models
|
|
41
|
+
TASKS.each do |task|
|
|
42
|
+
progress = records(PROGRESS).find_or_create_by!(task:)
|
|
43
|
+
next if progress.completed
|
|
44
|
+
|
|
45
|
+
unless progress.upper_id
|
|
46
|
+
upper = messages.order(messages.primary_key => :desc).pick(messages.primary_key)
|
|
47
|
+
unless upper
|
|
48
|
+
progress.update!(completed: true)
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
progress.update!(upper_id: upper&.to_s)
|
|
52
|
+
end
|
|
53
|
+
relation = legacy_messages
|
|
54
|
+
relation = relation.where("#{q(messages.primary_key)} <= ?", progress.upper_id) if progress.upper_id
|
|
55
|
+
relation = relation.where("#{q(messages.primary_key)} > ?", progress.last_id) if progress.last_id
|
|
56
|
+
each_batch(relation) do |batch|
|
|
57
|
+
messages.transaction do
|
|
58
|
+
copy_batch(task, batch)
|
|
59
|
+
progress.update!(last_id: batch.last.id)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
progress.update!(completed: true)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def catch_up(passes: nil)
|
|
67
|
+
sync_models
|
|
68
|
+
pass = 0
|
|
69
|
+
loop do
|
|
70
|
+
upper = changes.maximum(:id)
|
|
71
|
+
break unless upper
|
|
72
|
+
|
|
73
|
+
changes.where(id: ..upper).in_batches(of: 100) do |events|
|
|
74
|
+
captured = events.to_a
|
|
75
|
+
chat_ids = affected_chats(captured)
|
|
76
|
+
sync_chat_models(chat_ids)
|
|
77
|
+
reconcile_chats(chat_ids)
|
|
78
|
+
acknowledge(captured)
|
|
79
|
+
end
|
|
80
|
+
pass += 1
|
|
81
|
+
break if passes && pass >= passes
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def completed?
|
|
86
|
+
@connection.table_exists?(PROGRESS) &&
|
|
87
|
+
(TASKS - records(PROGRESS).where(completed: true).pluck(:task)).empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def finished?
|
|
91
|
+
@connection.table_exists?(PROGRESS) && records(PROGRESS).where(task: 'finished', completed: true).exists?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def sync_models
|
|
95
|
+
refresh = !finished?
|
|
96
|
+
records(configuration.fetch('model_table')).find_each(batch_size: BATCH_SIZE) do |model|
|
|
97
|
+
target = resolve_model(model.id)
|
|
98
|
+
next unless refresh
|
|
99
|
+
|
|
100
|
+
attributes = model.attributes.slice(*target.class.column_names).except(target.class.primary_key)
|
|
101
|
+
target.assign_attributes(attributes)
|
|
102
|
+
target.save! if target.changed?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sync_chat_models(ids = nil)
|
|
107
|
+
relation = chats.where(VERSION_COLUMN => 1)
|
|
108
|
+
relation = relation.where(chats.primary_key => ids) if ids
|
|
109
|
+
models = records(configuration.fetch('model_table')).all.to_h do |model|
|
|
110
|
+
[model.id, resolve_model(model.id).id]
|
|
111
|
+
end
|
|
112
|
+
models.each do |legacy_id, target_id|
|
|
113
|
+
relation.where(configuration.fetch('model_foreign_key') => legacy_id)
|
|
114
|
+
.where('ruby_llm_model_id IS NULL OR ruby_llm_model_id != ?', target_id)
|
|
115
|
+
.update_all(ruby_llm_model_id: target_id)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def verify
|
|
120
|
+
raise 'The online copy journal is not empty; keep AI activity paused' if changes.exists?
|
|
121
|
+
raise 'RubyLLM 2.0 backfills are incomplete' unless completed?
|
|
122
|
+
|
|
123
|
+
tables = [configuration.fetch('chat_table'), configuration.fetch('message_table'),
|
|
124
|
+
'ruby_llm_models', 'ruby_llm_tool_calls', 'ruby_llm_usages']
|
|
125
|
+
if @connection.adapter_name == 'PostgreSQL'
|
|
126
|
+
@connection.execute("ANALYZE #{tables.map { |table| qt(table) }.join(', ')}")
|
|
127
|
+
end
|
|
128
|
+
Verification.new(connection: @connection, settings: configuration).verify
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def cleanup
|
|
132
|
+
table = configuration.fetch('message_table')
|
|
133
|
+
if @connection.column_exists?(table, :ruby_llm_content)
|
|
134
|
+
@connection.remove_column(table, :content) if @connection.column_exists?(table, :content)
|
|
135
|
+
@connection.rename_column(table, :ruby_llm_content, :content)
|
|
136
|
+
end
|
|
137
|
+
columns = %i[legacy_key legacy_chat_id]
|
|
138
|
+
%i[ruby_llm_tool_calls ruby_llm_usages].each do |target|
|
|
139
|
+
columns.each do |column|
|
|
140
|
+
next unless @connection.column_exists?(target, column)
|
|
141
|
+
|
|
142
|
+
@connection.remove_index(target, column) if @connection.index_exists?(target, column)
|
|
143
|
+
@connection.remove_column(target, column)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def acknowledge(events)
|
|
151
|
+
matches = events.map { |event| "(id = #{Integer(event.id)} AND revision = #{Integer(event.revision)})" }
|
|
152
|
+
changes.where(matches.join(' OR ')).delete_all if matches.any?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def prepare_progress
|
|
156
|
+
unless @connection.table_exists?(PROGRESS)
|
|
157
|
+
@connection.create_table(PROGRESS) do |table|
|
|
158
|
+
table.string :task, null: false, index: { unique: true }
|
|
159
|
+
table.string :last_id
|
|
160
|
+
table.boolean :completed, null: false, default: false
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
@connection.add_column(PROGRESS, :upper_id, :string) unless @connection.column_exists?(PROGRESS, :upper_id)
|
|
164
|
+
records(PROGRESS).reset_column_information
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def copy_batch(task, batch)
|
|
168
|
+
case task
|
|
169
|
+
when 'message_content' then copy_content(batch.map(&:id))
|
|
170
|
+
when 'tool_results' then copy_tools(batch)
|
|
171
|
+
when 'usages' then upsert(:ruby_llm_usages, usage_attributes(batch))
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def copy_content(ids)
|
|
176
|
+
raw = @connection.column_exists?(messages.table_name, :content_raw) ? q(:content_raw) : 'NULL'
|
|
177
|
+
text = LegacyContentSQL.new(@connection).render(content: q(:content), raw:)
|
|
178
|
+
structured = @connection.adapter_name == 'PostgreSQL' ? "#{raw}::jsonb" : raw
|
|
179
|
+
messages.where(messages.primary_key => ids).update_all(
|
|
180
|
+
"#{q(:ruby_llm_content)} = #{text}, #{q(:raw_content)} = #{structured}"
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def copy_tools(batch)
|
|
185
|
+
ids = batch.map(&:id)
|
|
186
|
+
source = records(configuration.fetch('tool_call_table'))
|
|
187
|
+
key = configuration.fetch('message_foreign_key')
|
|
188
|
+
result_key = configuration.fetch('tool_call_foreign_key')
|
|
189
|
+
calls = source.where(key => ids).to_a
|
|
190
|
+
results = messages.where(result_key => calls.map(&:id)).to_a.group_by { |message| message[result_key] }
|
|
191
|
+
chat_ids = batch.to_h { |message| [message.id, message[configuration.fetch('chat_foreign_key')]] }
|
|
192
|
+
target = records(:ruby_llm_tool_calls)
|
|
193
|
+
attributes = calls.map do |call|
|
|
194
|
+
tool_attributes(call, results.fetch(call.id, []), chat_ids.fetch(call[key]), target)
|
|
195
|
+
end
|
|
196
|
+
verify_tool_ids(target, attributes)
|
|
197
|
+
upsert(:ruby_llm_tool_calls, attributes)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def tool_attributes(call, results, chat_id, target)
|
|
201
|
+
raise "Multiple messages reference tool call #{call.id}" if results.size > 1
|
|
202
|
+
|
|
203
|
+
call.attributes.slice(*target.column_names).except(target.primary_key).symbolize_keys.merge(
|
|
204
|
+
legacy_key: call.id.to_s, legacy_chat_id: chat_id.to_s,
|
|
205
|
+
message_type: configuration.fetch('message_class'),
|
|
206
|
+
message_id: call[configuration.fetch('message_foreign_key')],
|
|
207
|
+
result_type: results.any? ? configuration.fetch('message_class') : nil, result_id: results.first&.id
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def verify_tool_ids(target, attributes)
|
|
212
|
+
provider_ids = attributes.map { |row| row[:tool_call_id] }
|
|
213
|
+
reserved = target.where(tool_call_id: provider_ids).pluck(:tool_call_id, :legacy_key).to_h
|
|
214
|
+
attributes.each do |row|
|
|
215
|
+
next unless reserved.key?(row[:tool_call_id]) && reserved[row[:tool_call_id]] != row[:legacy_key]
|
|
216
|
+
|
|
217
|
+
raise "Tool call #{row[:legacy_key]} repeats provider ID #{row[:tool_call_id].inspect}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def usage_attributes(batch)
|
|
222
|
+
models = records(configuration.fetch('model_table')).all.index_by(&:id)
|
|
223
|
+
chat_ids = batch.map { |message| message[configuration.fetch('chat_foreign_key')] }
|
|
224
|
+
chat_models = chats.where(chats.primary_key => chat_ids)
|
|
225
|
+
.pluck(chats.primary_key, configuration.fetch('model_foreign_key')).to_h
|
|
226
|
+
batch.filter_map do |message|
|
|
227
|
+
source = message.attributes
|
|
228
|
+
tokens = legacy_tokens(source)
|
|
229
|
+
costs = legacy_costs(source)
|
|
230
|
+
next unless source['role'] == 'assistant' || (tokens.values + costs.values).any? { |value| !value.nil? }
|
|
231
|
+
|
|
232
|
+
chat_id = source.fetch(configuration.fetch('chat_foreign_key'))
|
|
233
|
+
provider, model_id = usage_identity(message, models, chat_models[chat_id])
|
|
234
|
+
|
|
235
|
+
tokens.merge(costs).symbolize_keys.merge(
|
|
236
|
+
legacy_key: message.id.to_s, chat_type: configuration.fetch('chat_class'), chat_id:,
|
|
237
|
+
message_type: configuration.fetch('message_class'), message_id: message.id,
|
|
238
|
+
operation: 'chat', provider:, model: model_id, status: 'succeeded',
|
|
239
|
+
created_at: source['created_at'], updated_at: source['updated_at']
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def usage_identity(message, models, chat_model_id)
|
|
245
|
+
key = configuration.fetch('model_foreign_key')
|
|
246
|
+
model = models[message[key]] || models[chat_model_id]
|
|
247
|
+
if string_model_reference?(message, key)
|
|
248
|
+
provider = message.attributes['provider'] || model&.provider
|
|
249
|
+
raise "Message #{message.id} has no identifiable provider" unless provider
|
|
250
|
+
|
|
251
|
+
return [provider, message[key]]
|
|
252
|
+
end
|
|
253
|
+
raise "Message #{message.id} has no identifiable legacy model" unless model
|
|
254
|
+
|
|
255
|
+
[model.provider, model.model_id]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def upsert(table, attributes)
|
|
259
|
+
return if attributes.empty?
|
|
260
|
+
|
|
261
|
+
options = { record_timestamps: false }
|
|
262
|
+
options[:unique_by] = :legacy_key unless @connection.adapter_name == 'Mysql2'
|
|
263
|
+
records(table).upsert_all(attributes, **options)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def string_model_reference?(message, key)
|
|
267
|
+
messages.columns_hash[key]&.type == :string && message[key]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def affected_chats(events)
|
|
271
|
+
ids = events.select { |event| event.kind == 'chat' }.map(&:record_id)
|
|
272
|
+
model_ids = events.select { |event| event.kind == 'model' }.map(&:record_id)
|
|
273
|
+
if model_ids.any?
|
|
274
|
+
sync_models
|
|
275
|
+
key = configuration.fetch('model_foreign_key')
|
|
276
|
+
ids.concat(chats.where(key => model_ids).pluck(chats.primary_key))
|
|
277
|
+
ids.concat(messages.where(key => model_ids).distinct.pluck(configuration.fetch('chat_foreign_key')))
|
|
278
|
+
end
|
|
279
|
+
ids.map(&:to_s).uniq
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def reconcile_chats(ids)
|
|
283
|
+
protected_ids = chats.where(chats.primary_key => ids,
|
|
284
|
+
VERSION_COLUMN => 2).pluck(chats.primary_key).map(&:to_s)
|
|
285
|
+
ids -= protected_ids
|
|
286
|
+
return if ids.empty?
|
|
287
|
+
|
|
288
|
+
remove_deleted_copies(ids)
|
|
289
|
+
relation = legacy_messages.where(configuration.fetch('chat_foreign_key') => ids)
|
|
290
|
+
upper = relation.order(messages.primary_key => :desc).pick(messages.primary_key)
|
|
291
|
+
relation = relation.where("#{q(messages.primary_key)} <= ?", upper) if upper
|
|
292
|
+
each_batch(relation) do |batch|
|
|
293
|
+
messages.transaction do
|
|
294
|
+
TASKS.each { |task| copy_batch(task, batch) }
|
|
295
|
+
eligible = usage_attributes(batch).map { |row| row[:legacy_key] }
|
|
296
|
+
records(:ruby_llm_usages).where(legacy_key: batch.map { |message| message.id.to_s })
|
|
297
|
+
.where.not(legacy_key: eligible).delete_all
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
remove_deleted_copies(ids)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def remove_deleted_copies(ids)
|
|
304
|
+
current = messages.select(messages.primary_key)
|
|
305
|
+
records(:ruby_llm_usages).where(chat_type: configuration.fetch('chat_class'), chat_id: ids)
|
|
306
|
+
.where.not(legacy_key: nil).where.not(message_id: current).delete_all
|
|
307
|
+
source = records(configuration.fetch('tool_call_table'))
|
|
308
|
+
records(:ruby_llm_tool_calls).where(legacy_chat_id: ids).where.not(legacy_key: nil).find_in_batches do |batch|
|
|
309
|
+
existing = source.where(source.primary_key => batch.map(&:legacy_key)).pluck(source.primary_key).map(&:to_s)
|
|
310
|
+
removed = batch.reject { |call| existing.include?(call.legacy_key) }.map(&:id)
|
|
311
|
+
records(:ruby_llm_tool_calls).where(id: removed).delete_all if removed.any?
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def each_batch(relation, &)
|
|
316
|
+
relation.find_in_batches(batch_size: BATCH_SIZE, &)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def legacy_messages
|
|
320
|
+
owned = chats.where(VERSION_COLUMN => 1).select(chats.primary_key)
|
|
321
|
+
messages.where(configuration.fetch('chat_foreign_key') => owned)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def messages = records(configuration.fetch('message_table'))
|
|
325
|
+
def chats = records(configuration.fetch('chat_table'))
|
|
326
|
+
def changes = records(Journal::TABLE)
|
|
327
|
+
def q(value) = @connection.quote_column_name(value)
|
|
328
|
+
def qt(value) = @connection.quote_table_name(value)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Generators
|
|
5
|
+
class OnlineCopyMigration < UpgradeMigration
|
|
6
|
+
class Journal # :nodoc: all
|
|
7
|
+
TABLE = :ruby_llm_v2_changes
|
|
8
|
+
OPERATIONS = %w[INSERT UPDATE DELETE].freeze
|
|
9
|
+
MESSAGE_COLUMNS = %w[
|
|
10
|
+
role content content_raw input_tokens output_tokens cached_tokens cache_creation_tokens
|
|
11
|
+
cache_read_tokens cache_write_tokens thinking_tokens total_cost cost_details created_at updated_at
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(connection:, settings:)
|
|
15
|
+
@connection = connection
|
|
16
|
+
@settings = settings
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def install
|
|
20
|
+
unless @connection.table_exists?(TABLE)
|
|
21
|
+
@connection.create_table(TABLE) do |table|
|
|
22
|
+
table.string :kind, null: false
|
|
23
|
+
table.string :record_id, null: false
|
|
24
|
+
table.bigint :revision, null: false, default: 1
|
|
25
|
+
table.index %i[kind record_id], unique: true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
sources.each do |kind, table|
|
|
29
|
+
OPERATIONS.each { |operation| install_trigger(kind, table, operation) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def remove
|
|
34
|
+
sources.each do |kind, table|
|
|
35
|
+
OPERATIONS.each do |operation|
|
|
36
|
+
name = trigger_name(kind, operation)
|
|
37
|
+
if postgresql?
|
|
38
|
+
if @connection.table_exists?(table)
|
|
39
|
+
@connection.execute("DROP TRIGGER IF EXISTS #{q(name)} ON #{qt(table)}")
|
|
40
|
+
end
|
|
41
|
+
@connection.execute("DROP FUNCTION IF EXISTS #{q(name)}()")
|
|
42
|
+
else
|
|
43
|
+
@connection.execute("DROP TRIGGER IF EXISTS #{q(name)}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
@connection.drop_table(TABLE) if @connection.table_exists?(TABLE)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def sources
|
|
53
|
+
%w[chat message tool_call model].to_h { |kind| [kind, @settings.fetch("#{kind}_table")] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def install_trigger(kind, table, operation)
|
|
57
|
+
name = trigger_name(kind, operation)
|
|
58
|
+
return if trigger_exists?(name)
|
|
59
|
+
|
|
60
|
+
body = event_sql(kind, operation)
|
|
61
|
+
condition = operation == 'UPDATE' ? changed_sql(kind, table) : '1 = 1'
|
|
62
|
+
state = qt(UpgradeMigration::TABLE)
|
|
63
|
+
condition = "(#{condition}) AND EXISTS (SELECT 1 FROM #{state} WHERE active_version = 1)"
|
|
64
|
+
sql = if postgresql?
|
|
65
|
+
<<~SQL
|
|
66
|
+
CREATE FUNCTION #{q(name)}() RETURNS trigger LANGUAGE plpgsql AS $ruby_llm$
|
|
67
|
+
BEGIN
|
|
68
|
+
IF #{condition} THEN #{body} END IF;
|
|
69
|
+
RETURN NULL;
|
|
70
|
+
END $ruby_llm$;
|
|
71
|
+
CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)}
|
|
72
|
+
FOR EACH ROW EXECUTE FUNCTION #{q(name)}();
|
|
73
|
+
SQL
|
|
74
|
+
elsif mysql?
|
|
75
|
+
<<~SQL
|
|
76
|
+
CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)} FOR EACH ROW
|
|
77
|
+
BEGIN IF #{condition} THEN #{body} END IF; END
|
|
78
|
+
SQL
|
|
79
|
+
else
|
|
80
|
+
<<~SQL
|
|
81
|
+
CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)} FOR EACH ROW
|
|
82
|
+
WHEN #{condition} BEGIN #{body} END
|
|
83
|
+
SQL
|
|
84
|
+
end
|
|
85
|
+
@connection.execute(sql)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def trigger_exists?(name)
|
|
89
|
+
quoted = @connection.quote(name)
|
|
90
|
+
sql = if postgresql?
|
|
91
|
+
"SELECT 1 FROM pg_trigger WHERE tgname = #{quoted} AND tgrelid IN " \
|
|
92
|
+
'(SELECT oid FROM pg_class WHERE relnamespace = current_schema()::regnamespace)'
|
|
93
|
+
elsif mysql?
|
|
94
|
+
"SELECT 1 FROM information_schema.triggers WHERE trigger_name = #{quoted} " \
|
|
95
|
+
'AND trigger_schema = DATABASE()'
|
|
96
|
+
else
|
|
97
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = #{quoted}"
|
|
98
|
+
end
|
|
99
|
+
@connection.select_value(sql).present?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def changed_sql(kind, table)
|
|
103
|
+
columns = @connection.columns(table).map(&:name)
|
|
104
|
+
columns &= source_columns(kind) unless kind == 'tool_call'
|
|
105
|
+
columns.map do |column|
|
|
106
|
+
old = "OLD.#{q(column)}"
|
|
107
|
+
new = "NEW.#{q(column)}"
|
|
108
|
+
if postgresql?
|
|
109
|
+
"#{old}::text IS DISTINCT FROM #{new}::text"
|
|
110
|
+
elsif mysql?
|
|
111
|
+
"NOT (CAST(#{old} AS BINARY) <=> CAST(#{new} AS BINARY))"
|
|
112
|
+
else
|
|
113
|
+
"#{old} IS NOT #{new}"
|
|
114
|
+
end
|
|
115
|
+
end.join(' OR ')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def source_columns(kind)
|
|
119
|
+
primary = @connection.primary_key(sources.fetch(kind))
|
|
120
|
+
case kind
|
|
121
|
+
when 'chat'
|
|
122
|
+
[primary, @settings.fetch('model_foreign_key')]
|
|
123
|
+
when 'message'
|
|
124
|
+
[primary,
|
|
125
|
+
*@settings.values_at('chat_foreign_key', 'model_foreign_key', 'tool_call_foreign_key')] + MESSAGE_COLUMNS
|
|
126
|
+
when 'model'
|
|
127
|
+
[primary, 'provider', 'model_id']
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def event_sql(kind, operation)
|
|
132
|
+
images = if operation == 'UPDATE'
|
|
133
|
+
%w[OLD NEW]
|
|
134
|
+
else
|
|
135
|
+
[operation == 'INSERT' ? 'NEW' : 'OLD']
|
|
136
|
+
end
|
|
137
|
+
images.map do |row|
|
|
138
|
+
id = event_owner(kind, row)
|
|
139
|
+
event = kind == 'model' ? 'model' : 'chat'
|
|
140
|
+
"INSERT INTO #{qt(TABLE)} (kind, record_id) SELECT '#{event}', #{id} WHERE #{id} IS NOT NULL #{upsert_sql};"
|
|
141
|
+
end.join("\n")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def event_owner(kind, row)
|
|
145
|
+
case kind
|
|
146
|
+
when 'message'
|
|
147
|
+
"#{row}.#{q(@settings.fetch('chat_foreign_key'))}"
|
|
148
|
+
when 'tool_call'
|
|
149
|
+
"(SELECT #{q(@settings.fetch('chat_foreign_key'))} FROM #{qt(sources.fetch('message'))} " \
|
|
150
|
+
"WHERE #{q(@connection.primary_key(sources.fetch('message')))} = " \
|
|
151
|
+
"#{row}.#{q(@settings.fetch('message_foreign_key'))})"
|
|
152
|
+
else
|
|
153
|
+
"#{row}.#{q(@connection.primary_key(sources.fetch(kind)))}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def upsert_sql
|
|
158
|
+
return 'ON DUPLICATE KEY UPDATE revision = revision + 1' if mysql?
|
|
159
|
+
|
|
160
|
+
"ON CONFLICT (kind, record_id) DO UPDATE SET revision = #{qt(TABLE)}.revision + 1"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def trigger_name(kind, operation) = "ruby_llm_v2_#{kind}_#{operation.downcase}"
|
|
164
|
+
def postgresql? = @connection.adapter_name == 'PostgreSQL'
|
|
165
|
+
def mysql? = @connection.adapter_name == 'Mysql2'
|
|
166
|
+
def q(value) = @connection.quote_column_name(value)
|
|
167
|
+
def qt(value) = @connection.quote_table_name(value)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|