ragdoll 0.1.0 → 0.1.3

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +318 -40
  3. data/Rakefile +66 -4
  4. data/app/jobs/ragdoll/extract_keywords_job.rb +28 -0
  5. data/app/jobs/ragdoll/extract_text_job.rb +38 -0
  6. data/app/jobs/ragdoll/generate_embeddings_job.rb +28 -0
  7. data/app/jobs/ragdoll/generate_summary_job.rb +25 -0
  8. data/app/lib/ragdoll/metadata_schemas.rb +332 -0
  9. data/app/models/ragdoll/audio_content.rb +142 -0
  10. data/app/models/ragdoll/content.rb +95 -0
  11. data/app/models/ragdoll/document.rb +606 -4
  12. data/app/models/ragdoll/embedding.rb +172 -5
  13. data/app/models/ragdoll/image_content.rb +194 -0
  14. data/app/models/ragdoll/text_content.rb +137 -0
  15. data/app/services/ragdoll/configuration_service.rb +113 -0
  16. data/app/services/ragdoll/document_management.rb +108 -0
  17. data/app/services/ragdoll/document_processor.rb +342 -0
  18. data/app/services/ragdoll/embedding_service.rb +202 -0
  19. data/app/services/ragdoll/image_description_service.rb +230 -0
  20. data/app/services/ragdoll/metadata_generator.rb +329 -0
  21. data/app/services/ragdoll/model_resolver.rb +72 -0
  22. data/app/services/ragdoll/search_engine.rb +51 -0
  23. data/app/services/ragdoll/text_chunker.rb +208 -0
  24. data/app/services/ragdoll/text_generation_service.rb +355 -0
  25. data/db/migrate/001_enable_postgresql_extensions.rb +23 -0
  26. data/db/migrate/004_create_ragdoll_documents.rb +70 -0
  27. data/db/migrate/005_create_ragdoll_embeddings.rb +41 -0
  28. data/db/migrate/006_create_ragdoll_contents.rb +47 -0
  29. data/lib/ragdoll/core/client.rb +306 -0
  30. data/lib/ragdoll/core/configuration.rb +257 -0
  31. data/lib/ragdoll/core/database.rb +141 -0
  32. data/lib/ragdoll/core/errors.rb +11 -0
  33. data/lib/ragdoll/core/model.rb +45 -0
  34. data/lib/ragdoll/core/shrine_config.rb +71 -0
  35. data/lib/ragdoll/core/version.rb +8 -0
  36. data/lib/ragdoll/core.rb +91 -0
  37. data/lib/ragdoll-core.rb +3 -0
  38. data/lib/ragdoll.rb +243 -6
  39. data/lib/tasks/annotate.rake +126 -0
  40. data/lib/tasks/db.rake +338 -0
  41. metadata +42 -35
  42. data/config/initializers/ragdoll.rb +0 -6
  43. data/config/routes.rb +0 -5
  44. data/db/migrate/20250218123456_create_documents.rb +0 -20
  45. data/lib/config/database.yml +0 -28
  46. data/lib/config/ragdoll.yml +0 -31
  47. data/lib/ragdoll/engine.rb +0 -16
  48. data/lib/ragdoll/import_job.rb +0 -15
  49. data/lib/ragdoll/ingestion.rb +0 -30
  50. data/lib/ragdoll/search.rb +0 -18
  51. data/lib/ragdoll/version.rb +0 -7
  52. data/lib/tasks/import_task.thor +0 -32
  53. data/lib/tasks/jobs_task.thor +0 -40
  54. data/lib/tasks/ragdoll_tasks.thor +0 -7
  55. data/lib/tasks/search_task.thor +0 -55
@@ -1,9 +1,611 @@
1
- # This file defines the Document model for the Ragdoll gem.
2
-
3
1
  # frozen_string_literal: true
4
2
 
3
+ require "active_record"
4
+
5
5
  module Ragdoll
6
- class Document < ApplicationRecord
7
- has_many :embeddings, dependent: :destroy
6
+ class Document < ActiveRecord::Base
7
+ self.table_name = "ragdoll_documents"
8
+
9
+ # PostgreSQL full-text search on summary and keywords
10
+ # Uses PostgreSQL's built-in full-text search capabilities
11
+
12
+ # File handling moved to content models - no Shrine attachment at document level
13
+
14
+ # Multi-modal content relationships using STI
15
+ has_many :contents,
16
+ class_name: "Ragdoll::Content",
17
+ foreign_key: "document_id",
18
+ dependent: :destroy
19
+
20
+ has_many :text_contents,
21
+ -> { where(type: "Ragdoll::TextContent") },
22
+ class_name: "Ragdoll::TextContent",
23
+ foreign_key: "document_id"
24
+
25
+ has_many :image_contents,
26
+ -> { where(type: "Ragdoll::ImageContent") },
27
+ class_name: "Ragdoll::ImageContent",
28
+ foreign_key: "document_id"
29
+
30
+ has_many :audio_contents,
31
+ -> { where(type: "Ragdoll::AudioContent") },
32
+ class_name: "Ragdoll::AudioContent",
33
+ foreign_key: "document_id"
34
+
35
+ # All embeddings across content types
36
+ has_many :text_embeddings, through: :text_contents, source: :embeddings
37
+ has_many :image_embeddings, through: :image_contents, source: :embeddings
38
+ has_many :audio_embeddings, through: :audio_contents, source: :embeddings
39
+
40
+ validates :location, presence: true
41
+ validates :title, presence: true
42
+ validates :document_type, presence: true,
43
+ inclusion: { in: %w[text image audio pdf docx html markdown mixed] }
44
+ validates :summary, presence: false # Allow empty summaries initially
45
+ validates :keywords, presence: false # Allow empty keywords initially
46
+ validates :status, inclusion: { in: %w[pending processing processed error] }
47
+ validates :file_modified_at, presence: true
48
+
49
+ # Ensure location is always an absolute path for file paths
50
+ before_validation :normalize_location
51
+ before_validation :set_default_file_modified_at
52
+
53
+ # JSON columns are handled natively by PostgreSQL - no serialization needed
54
+
55
+ scope :processed, -> { where(status: "processed") }
56
+ scope :by_type, ->(type) { where(document_type: type) }
57
+ scope :recent, -> { order(created_at: :desc) }
58
+ scope :with_content, -> { joins(:contents).distinct }
59
+ scope :without_content, -> { left_joins(:contents).where(contents: { id: nil }) }
60
+
61
+ # Callbacks to process content
62
+ after_commit :create_content_from_pending, on: %i[create update],
63
+ if: :has_pending_content?
64
+
65
+ def processed?
66
+ status == "processed"
67
+ end
68
+
69
+ # Multi-modal content type detection
70
+ def multi_modal?
71
+ content_types.length > 1
72
+ end
73
+
74
+ def content_types
75
+ %w[text image audio].select do |type|
76
+ send("#{type}_contents").any?
77
+ end
78
+ end
79
+
80
+ def primary_content_type
81
+ return document_type if %w[text image audio].include?(document_type)
82
+ return content_types.first if content_types.any?
83
+
84
+ "text" # default
85
+ end
86
+
87
+ # Dynamic content method that forwards to appropriate content table
88
+ def content
89
+ type = primary_content_type
90
+
91
+ if %w[text image audio].include?(type)
92
+ # Return the combined content from the appropriate content type
93
+ # For text: actual text content
94
+ # For image: AI-generated descriptions (stored in content field)
95
+ # For audio: transcripts (stored in content field)
96
+ send("#{type}_contents").pluck(:content).compact.join("\n\n")
97
+ else
98
+ # Fallback: try to get any available content
99
+ contents.pluck(:content).compact.join("\n\n")
100
+ end
101
+ end
102
+
103
+ # Set content method for backwards compatibility
104
+ def content=(value)
105
+ # Store the content to be created after save
106
+ @pending_content = value
107
+
108
+ # If document is already persisted, create the content immediately
109
+ return unless persisted?
110
+
111
+ create_content_from_pending
112
+ end
113
+
114
+ # Content statistics
115
+ def total_word_count
116
+ text_contents.sum { |tc| tc.word_count }
117
+ end
118
+
119
+ def total_character_count
120
+ text_contents.sum { |tc| tc.character_count }
121
+ end
122
+
123
+ def total_embedding_count
124
+ %w[text image audio].sum { |type| send("#{type}_embeddings").count }
125
+ end
126
+
127
+ def embeddings_by_type
128
+ %w[text image audio].each_with_object({}) do |type, result|
129
+ result[type.to_sym] = send("#{type}_embeddings").count
130
+ end
131
+ end
132
+
133
+ # Document metadata methods - now using dedicated columns
134
+ def has_summary?
135
+ summary.present?
136
+ end
137
+
138
+ def has_keywords?
139
+ keywords.present?
140
+ end
141
+
142
+ def keywords_array
143
+ return [] unless keywords.present?
144
+
145
+ case keywords
146
+ when Array
147
+ keywords
148
+ when String
149
+ keywords.split(",").map(&:strip).reject(&:empty?)
150
+ else
151
+ []
152
+ end
153
+ end
154
+
155
+ def add_keyword(keyword)
156
+ current_keywords = keywords_array
157
+ return if current_keywords.include?(keyword.strip)
158
+
159
+ current_keywords << keyword.strip
160
+ self.keywords = current_keywords.join(", ")
161
+ end
162
+
163
+ def remove_keyword(keyword)
164
+ current_keywords = keywords_array
165
+ current_keywords.delete(keyword.strip)
166
+ self.keywords = current_keywords.join(", ")
167
+ end
168
+
169
+ # Metadata accessors for common fields
170
+ def description
171
+ metadata["description"]
172
+ end
173
+
174
+ def description=(value)
175
+ self.metadata = metadata.merge("description" => value)
176
+ end
177
+
178
+ def classification
179
+ metadata["classification"]
180
+ end
181
+
182
+ def classification=(value)
183
+ self.metadata = metadata.merge("classification" => value)
184
+ end
185
+
186
+ def tags
187
+ metadata["tags"] || []
188
+ end
189
+
190
+ def tags=(value)
191
+ self.metadata = metadata.merge("tags" => Array(value))
192
+ end
193
+
194
+ # File-related helper methods - now delegated to content models
195
+ def has_files?
196
+ contents.any? { |c| c.data.present? }
197
+ end
198
+
199
+ def total_file_size
200
+ # Could be implemented by summing file sizes from content metadata
201
+ contents.sum { |c| c.metadata.dig("file_size") || 0 }
202
+ end
203
+
204
+ def primary_file_type
205
+ # Return the document_type as the primary file type
206
+ document_type
207
+ end
208
+
209
+ # Content processing for multi-modal documents
210
+ def process_content!
211
+ # Content processing is now handled by individual content models
212
+ # This method orchestrates the overall processing
213
+
214
+ # Generate embeddings for all content
215
+ generate_embeddings_for_all_content!
216
+
217
+ # Generate structured metadata using LLM
218
+ generate_metadata!
219
+
220
+ update!(status: "processed")
221
+ end
222
+
223
+ # Generate embeddings for all content types
224
+ def generate_embeddings_for_all_content!
225
+ %w[text image audio].each do |type|
226
+ send("#{type}_contents").each(&:generate_embeddings!)
227
+ end
228
+ end
229
+
230
+ # Generate structured metadata using LLM
231
+ def generate_metadata!
232
+ require_relative "../../lib/ragdoll/core/services/metadata_generator"
233
+
234
+ generator = Ragdoll::MetadataGenerator.new
235
+ generated_metadata = generator.generate_for_document(self)
236
+
237
+ # Validate metadata against schema
238
+ errors = Ragdoll::MetadataSchemas.validate_metadata(document_type, generated_metadata)
239
+ if errors.any?
240
+ Rails.logger.warn "Metadata validation errors: #{errors.join(', ')}" if defined?(Rails)
241
+ puts "Metadata validation errors: #{errors.join(', ')}"
242
+ end
243
+
244
+ # Merge with existing metadata (preserving user-set values)
245
+ self.metadata = metadata.merge(generated_metadata)
246
+ save!
247
+ rescue StandardError => e
248
+ Rails.logger.error "Metadata generation failed: #{e.message}" if defined?(Rails)
249
+ puts "Metadata generation failed: #{e.message}"
250
+ end
251
+
252
+ # PostgreSQL full-text search on metadata fields
253
+ def self.search_content(query, **options)
254
+ return none if query.blank?
255
+
256
+ # Use PostgreSQL's built-in full-text search across metadata fields
257
+ where(
258
+ "to_tsvector('english', COALESCE(title, '') || ' ' || COALESCE(metadata->>'summary', '') || ' ' || COALESCE(metadata->>'keywords', '') || ' ' || COALESCE(metadata->>'description', '')) @@ plainto_tsquery('english', ?)",
259
+ query
260
+ ).limit(options[:limit] || 20)
261
+ end
262
+
263
+ # Faceted search by metadata fields
264
+ def self.faceted_search(query: nil, keywords: [], classification: nil, tags: [], **options)
265
+ scope = all
266
+
267
+ # Filter by keywords if provided
268
+ if keywords.any?
269
+ keywords.each do |keyword|
270
+ scope = scope.where("metadata->>'keywords' ILIKE ?", "%#{keyword}%")
271
+ end
272
+ end
273
+
274
+ # Filter by classification
275
+ scope = scope.where("metadata->>'classification' = ?", classification) if classification.present?
276
+
277
+ # Filter by tags
278
+ if tags.any?
279
+ tags.each do |tag|
280
+ scope = scope.where("metadata ? 'tags' AND metadata->'tags' @> ?", [tag].to_json)
281
+ end
282
+ end
283
+
284
+ # Apply PostgreSQL full-text search if query provided
285
+ if query.present?
286
+ scope = scope.where(
287
+ "to_tsvector('english', COALESCE(title, '') || ' ' || COALESCE(metadata->>'summary', '') || ' ' || COALESCE(metadata->>'keywords', '') || ' ' || COALESCE(metadata->>'description', '')) @@ plainto_tsquery('english', ?)",
288
+ query
289
+ )
290
+ end
291
+
292
+ scope.limit(options[:limit] || 20)
293
+ end
294
+
295
+ # Get all unique keywords from metadata
296
+ def self.all_keywords
297
+ keywords = []
298
+ where("metadata ? 'keywords'").pluck(:metadata).each do |meta|
299
+ case meta["keywords"]
300
+ when Array
301
+ keywords.concat(meta["keywords"])
302
+ when String
303
+ keywords.concat(meta["keywords"].split(",").map(&:strip))
304
+ end
305
+ end
306
+ keywords.uniq.sort
307
+ end
308
+
309
+ # Get all unique classifications
310
+ def self.all_classifications
311
+ where("metadata ? 'classification'").distinct.pluck("metadata->>'classification'").compact.sort
312
+ end
313
+
314
+ # Get all unique tags
315
+ def self.all_tags
316
+ tags = []
317
+ where("metadata ? 'tags'").pluck(:metadata).each do |meta|
318
+ tags.concat(Array(meta["tags"]))
319
+ end
320
+ tags.uniq.sort
321
+ end
322
+
323
+ # Get keyword frequencies for faceted search
324
+ def self.keyword_frequencies
325
+ frequencies = Hash.new(0)
326
+ where("metadata ? 'keywords'").pluck(:metadata).each do |meta|
327
+ case meta["keywords"]
328
+ when Array
329
+ meta["keywords"].each { |k| frequencies[k] += 1 }
330
+ when String
331
+ meta["keywords"].split(",").map(&:strip).each { |k| frequencies[k] += 1 }
332
+ end
333
+ end
334
+ frequencies.sort_by { |_k, v| -v }.to_h
335
+ end
336
+
337
+ # Hybrid search combining semantic and PostgreSQL full-text search
338
+ def self.hybrid_search(query, query_embedding: nil, **options)
339
+ limit = options[:limit] || 20
340
+ semantic_weight = options[:semantic_weight] || 0.7
341
+ text_weight = options[:text_weight] || 0.3
342
+
343
+ results = []
344
+
345
+ # Get semantic search results if embedding provided
346
+ if query_embedding
347
+ semantic_results = embeddings_search(query_embedding, limit: limit)
348
+ results.concat(semantic_results.map do |result|
349
+ result.merge(
350
+ search_type: "semantic",
351
+ weighted_score: result[:combined_score] * semantic_weight
352
+ )
353
+ end)
354
+ end
355
+
356
+ # Get PostgreSQL full-text search results
357
+ text_results = search_content(query, limit: limit)
358
+ text_results.each_with_index do |doc, index|
359
+ score = (limit - index).to_f / limit * text_weight
360
+ results << {
361
+ document_id: doc.id.to_s,
362
+ document_title: doc.title,
363
+ document_location: doc.location,
364
+ content: doc.content[0..500], # Preview
365
+ search_type: "full_text",
366
+ weighted_score: score,
367
+ document: doc
368
+ }
369
+ end
370
+
371
+ # Combine and deduplicate by document_id
372
+ combined = results.group_by { |r| r[:document_id] }
373
+ .map do |_doc_id, doc_results|
374
+ best_result = doc_results.max_by { |r| r[:weighted_score] }
375
+ total_score = doc_results.sum { |r| r[:weighted_score] }
376
+ search_types = doc_results.map { |r| r[:search_type] }.uniq
377
+
378
+ best_result.merge(
379
+ combined_score: total_score,
380
+ search_types: search_types
381
+ )
382
+ end
383
+
384
+ combined.sort_by { |r| -r[:combined_score] }.take(limit)
385
+ end
386
+
387
+ # Extract keywords from query string (words > 4 characters)
388
+ def self.extract_keywords(query:)
389
+ return [] if query.nil? || query.strip.empty?
390
+
391
+ query.split(/\s+/)
392
+ .map(&:strip)
393
+ .reject(&:empty?)
394
+ .select { |word| word.length > 4 }
395
+ end
396
+
397
+ # Get search data for indexing
398
+ def search_data
399
+ data = {
400
+ title: title,
401
+ document_type: document_type,
402
+ location: location,
403
+ status: status,
404
+ total_word_count: total_word_count,
405
+ total_character_count: total_character_count,
406
+ total_embedding_count: total_embedding_count,
407
+ content_types: content_types,
408
+ multi_modal: multi_modal?
409
+ }
410
+
411
+ # Add document metadata
412
+ data.merge!(metadata.transform_keys { |k| "metadata_#{k}" }) if metadata.present?
413
+
414
+ # Add file metadata
415
+ data.merge!(file_metadata.transform_keys { |k| "file_#{k}" }) if file_metadata.present?
416
+
417
+ data
418
+ end
419
+
420
+ def all_embeddings(content_type: nil)
421
+ content_ids = []
422
+
423
+ content_types = content_type ? [content_type.to_s] : %w[text image audio]
424
+
425
+ content_types.each do |type|
426
+ content_relation = send("#{type}_contents")
427
+ content_ids.concat(content_relation.pluck(:id)) if content_relation.any?
428
+ end
429
+
430
+ return Ragdoll::Embedding.none if content_ids.empty?
431
+
432
+ # Use the base STI class name 'Ragdoll::Content' as that's what's stored
433
+ # in polymorphic associations with STI
434
+ Ragdoll::Embedding.where(
435
+ embeddable_type: "Ragdoll::Content",
436
+ embeddable_id: content_ids
437
+ )
438
+ end
439
+
440
+ private
441
+
442
+ def has_pending_content?
443
+ @pending_content.present?
444
+ end
445
+
446
+ def create_content_from_pending
447
+ return unless @pending_content.present?
448
+
449
+ value = @pending_content
450
+ @pending_content = nil
451
+
452
+ case primary_content_type
453
+ when "text"
454
+ # Create or update the first text_content
455
+ if text_contents.any?
456
+ text_contents.first.update!(content: value)
457
+ else
458
+ text_contents.create!(
459
+ content: value,
460
+ embedding_model: default_text_model,
461
+ metadata: { manually_set: true }
462
+ )
463
+ end
464
+ when "image"
465
+ # For images, set the description (stored in content field)
466
+ if image_contents.any?
467
+ image_contents.first.update!(content: value) # content field stores description
468
+ else
469
+ image_contents.create!(
470
+ content: value, # content field stores description
471
+ embedding_model: default_image_model,
472
+ metadata: { manually_set: true }
473
+ )
474
+ end
475
+ when "audio"
476
+ # For audio, set the transcript (stored in content field)
477
+ if audio_contents.any?
478
+ audio_contents.first.update!(content: value) # content field stores transcript
479
+ else
480
+ audio_contents.create!(
481
+ content: value, # content field stores transcript
482
+ embedding_model: default_audio_model,
483
+ metadata: { manually_set: true }
484
+ )
485
+ end
486
+ else
487
+ # Default to text content
488
+ text_contents.create!(
489
+ content: value,
490
+ embedding_model: default_text_model,
491
+ metadata: { manually_set: true }
492
+ )
493
+ end
494
+ end
495
+
496
+ def self.embeddings_search(query_embedding, **options)
497
+ Ragdoll::Embedding.search_similar(query_embedding, **options)
498
+ end
499
+
500
+ # File processing is now handled by DocumentProcessor and content models
501
+ # These methods are no longer needed at the document level
502
+
503
+ # Default model names for each content type
504
+ def default_text_model
505
+ "text-embedding-3-large"
506
+ end
507
+
508
+ def default_image_model
509
+ "clip-vit-large-patch14"
510
+ end
511
+
512
+ def default_audio_model
513
+ "whisper-embedding-v1"
514
+ end
515
+
516
+ # File extraction is now handled by DocumentProcessor
517
+ # Content-specific extraction is handled by individual content models
518
+
519
+ # Get document statistics
520
+ def self.stats
521
+ {
522
+ total_documents: count,
523
+ by_status: group(:status).count,
524
+ by_type: group(:document_type).count,
525
+ multi_modal_documents: joins(:text_contents, :image_contents).distinct.count +
526
+ joins(:text_contents, :audio_contents).distinct.count +
527
+ joins(:image_contents, :audio_contents).distinct.count,
528
+ total_text_contents: joins(:text_contents).count,
529
+ total_image_contents: joins(:image_contents).count,
530
+ total_audio_contents: joins(:audio_contents).count,
531
+ total_embeddings: {
532
+ text: joins(:text_embeddings).count,
533
+ image: joins(:image_embeddings).count,
534
+ audio: joins(:audio_embeddings).count
535
+ },
536
+ storage_type: "activerecord_polymorphic"
537
+ }
538
+ end
539
+
540
+ public
541
+
542
+ # Convert document to hash representation for API responses
543
+ def to_hash(include_content: false)
544
+ {
545
+ id: id.to_s,
546
+ title: title,
547
+ location: location,
548
+ document_type: document_type,
549
+ status: status,
550
+ content_length: content&.length || 0,
551
+ file_modified_at: file_modified_at&.iso8601,
552
+ created_at: created_at&.iso8601,
553
+ updated_at: updated_at&.iso8601,
554
+ metadata: metadata || {},
555
+ content_summary: {
556
+ text_contents: text_contents.count,
557
+ image_contents: image_contents.count,
558
+ audio_contents: audio_contents.count,
559
+ embeddings_count: total_embeddings_count,
560
+ embeddings_ready: status == "processed"
561
+ }
562
+ }.tap do |hash|
563
+ if include_content
564
+ hash[:content_details] = {
565
+ text_content: text_contents.map(&:content),
566
+ image_descriptions: image_contents.map(&:description),
567
+ audio_transcripts: audio_contents.map(&:transcript)
568
+ }
569
+ end
570
+ end
571
+ end
572
+
573
+ private
574
+
575
+ def total_embeddings_count
576
+ # Count embeddings through polymorphic associations
577
+ %w[text image audio].sum do |type|
578
+ send("#{type}_contents").sum { |content| content.embeddings.count }
579
+ end
580
+ end
581
+
582
+ # Normalize location to absolute path for file paths
583
+ def normalize_location
584
+ return if location.blank?
585
+
586
+ # Don't normalize URLs or other non-file protocols
587
+ return if location.start_with?("http://", "https://", "ftp://", "sftp://")
588
+
589
+ # Convert relative file paths to absolute paths
590
+ self.location = File.expand_path(location)
591
+ end
592
+
593
+ # Set default file_modified_at if not provided
594
+ def set_default_file_modified_at
595
+ return if file_modified_at.present?
596
+
597
+ # If location is a file path that exists, use file mtime
598
+ if location.present? && !location.start_with?("http://", "https://", "ftp://", "sftp://")
599
+ expanded_location = File.expand_path(location)
600
+ self.file_modified_at = if File.exist?(expanded_location)
601
+ File.mtime(expanded_location)
602
+ else
603
+ Time.current
604
+ end
605
+ else
606
+ # For URLs or non-file locations, use current time
607
+ self.file_modified_at = Time.current
608
+ end
609
+ end
8
610
  end
9
611
  end