Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pairing job #33

Merged
merged 4 commits into from
Feb 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pairings-api/.env.test
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@

BASE_URL=http://localhost:3000
SIDEKIQ_PASSWORD=password
SIDEKIQ_REDIS_URL=redis://localhost:6379
REDIS_URL=redis://localhost:6379
SIDEKIQ_USERNAME=admin
SIDEKIQ_PASSWORD=password
DATABASE_USERNAME=admin
DATABASE_PASSWORD=123
DATABASE_HOST=localhost
Expand Down
1 change: 0 additions & 1 deletion pairings-api/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ gem 'anthropic'
# Background processing
gem 'redis'
gem 'sidekiq'
gem 'sidekiq-scheduler'

# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem 'solid_cable'
Expand Down
8 changes: 0 additions & 8 deletions pairings-api/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,6 @@ GEM
rubocop (~> 1.61)
rubocop-rspec (~> 3, >= 3.0.1)
ruby-progressbar (1.13.0)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
securerandom (0.4.1)
shoulda-matchers (6.4.0)
activesupport (>= 5.2.0)
Expand All @@ -395,10 +393,6 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-scheduler (5.0.6)
rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8)
tilt (>= 1.4.0, < 3)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
Expand Down Expand Up @@ -431,7 +425,6 @@ GEM
thruster (0.1.10-aarch64-linux)
thruster (0.1.10-arm64-darwin)
thruster (0.1.10-x86_64-linux)
tilt (2.6.0)
timeout (0.4.3)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
Expand Down Expand Up @@ -495,7 +488,6 @@ DEPENDENCIES
rubocop-pave
shoulda-matchers (~> 6.0)
sidekiq
sidekiq-scheduler
solid_cable
solid_cache
solid_queue
Expand Down
8 changes: 8 additions & 0 deletions pairings-api/app/jobs/attach_image_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class AttachImageJob < ApplicationJob
queue_as :default
retry_on OpenURI::HTTPError, SocketError, Net::OpenTimeout, attempts: 3, wait: 5.seconds

def perform(item, image_url)
item.attach_image_from_url(image_url)
end
end
48 changes: 48 additions & 0 deletions pairings-api/app/jobs/pairing_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
class PairingJob < ActiveJob::Base
class PairingJobError < StandardError; end

WAIT_TIME = 5.seconds

queue_as :default

def perform(item, user)
return unless item.image.attached?

blob_image = item.image.blob

response = PairingService.call(blob_image_id: blob_image.id)

return unless response[:success?]

ActiveRecord::Base.transaction do
Rails.logger.debug "Starting transaction"
item.update!(
response.dig(:payload, :item1).slice(*Item::FIELDS)
)

Rails.logger.debug "After update! - This line should never be reached if update! fails"


item2 = Item.create!(
response.dig(:payload, :item2)
.slice(*Item::FIELDS)
.merge(user: user)
)

Pairing.create!(
item1: item,
item2: item2,
user: user,
confidence_score: response.dig(:payload, :pairing, :confidence_score),
ai_reasoning: response.dig(:payload, :pairing, :ai_reasoning),
pairing_notes: response.dig(:payload, :pairing, :pairing_notes),
strength: response.dig(:payload, :pairing, :strength)
)

AttachImageJob.set(wait: WAIT_TIME).perform_later(item2, response.dig(:payload, :image_url))
end
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Failed to create pairing: #{e.message}"
raise PairingJobError, e.message
end
end
37 changes: 37 additions & 0 deletions pairings-api/app/models/item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ class Item < ApplicationRecord
'$$$$' => 'Luxury ($60+)'
}

FIELDS = [
:name,
:description,
:category,
:subcategory,
:flavor_profiles,
:primary_flavor_profile,
:price_range
].freeze

validates :price_range, inclusion: { in: PRICE_RANGES.keys }, presence: true
validates :image, content_type: { in: [:png, :jpeg], spoofing_protection: true }, size: { less_than: 5.megabytes }
validates :name, presence: true
Expand Down Expand Up @@ -96,6 +106,33 @@ def as_json(options = {})
})
end

def attach_image_from_url(url)
return unless url.present?

require 'open-uri'
begin
downloaded_image = URI.open(url)
content_type = downloaded_image.content_type

extension = case content_type
when 'image/png' then '.png'
else '.jpg'
end

self.image.attach(
io: downloaded_image,
filename: "item_#{id}_#{Time.current.to_i}#{extension}",
content_type: content_type,
identify: true
)
rescue OpenURI::HTTPError, SocketError, Net::OpenTimeout => e
raise e
rescue StandardError => e
errors.add(:image, "Failed to attach image from URL: #{e.message}")
false
end
end

private

def flavor_profiles_include_primary
Expand Down
34 changes: 22 additions & 12 deletions pairings-api/app/services/pairing_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,25 @@ class NoPairingFoundError < PairingServiceError; end

require 'base64'

def self.call(image_id:)
new(image_id).call
def self.call(blob_image_id:)
new(blob_image_id).call
end

def initialize(image_id)
@image_id = image_id
def initialize(blob_image_id)
@blob_image_id = blob_image_id
end

def call
return unless blob_image

response = get_pairing
formatted_response = get_pairing

{ success?: true, payload: response }
{ success?: true, payload: formatted_response }
end

private

attr_reader :image_id
attr_reader :blob_image_id

def get_pairing
begin
Expand All @@ -52,7 +52,7 @@ def get_pairing
end

def blob_image
@blob_image ||= ActiveStorage::Blob.find(image_id)
@blob_image ||= ActiveStorage::Blob.find(blob_image_id)
end

def client
Expand All @@ -74,15 +74,25 @@ def prompt

def parse_pairing_response(text_response)
lines = text_response.split("\n").reject(&:empty?)
fields = [:name, :description, :category, :subcategory, :flavor_profiles, :primary_flavor, :price_range, :attributes, :texture]

item1_values = lines[0].split('|').map(&:strip)
item2_values = lines[1].split('|').map(&:strip)

{
item_1: Hash[fields.zip(item1_values)].merge(flavor_profiles: item1_values[4].split(',')),
item_2: Hash[fields.zip(item2_values)].merge(flavor_profiles: item2_values[4].split(','))
}
item1: Hash[Item::FIELDS.zip(item1_values[0..6])].merge(
flavor_profiles: item1_values[4].split(',')
),
item2: Hash[Item::FIELDS.zip(item2_values[0..6])].merge(
flavor_profiles: item2_values[4].split(',')
),
image_url: item2_values[7],
pairing: {
confidence_score: item2_values[8].to_f,
ai_reasoning: item2_values[9],
pairing_notes: item2_values[10],
strength: item2_values[11].to_i
}
}.deep_symbolize_keys
rescue StandardError => e
raise ParseError, "Failed to parse response: #{e.message}"
end
Expand Down
1 change: 1 addition & 0 deletions pairings-api/config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@

# Highlight code that enqueued background job in logs.
config.active_job.verbose_enqueue_logs = true
config.active_job.queue_adapter = :sidekiq

# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
Expand Down
3 changes: 1 addition & 2 deletions pairings-api/config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@
config.cache_store = :solid_cache_store

# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
config.active_job.queue_adapter = :sidekiq

# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
Expand Down
2 changes: 2 additions & 0 deletions pairings-api/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
config.consider_all_requests_local = true
config.cache_store = :null_store

config.active_job.queue_adapter = :test

# Render exception templates for rescuable exceptions and raise for other exceptions.
config.action_dispatch.show_exceptions = :rescuable

Expand Down
7 changes: 7 additions & 0 deletions pairings-api/config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
Rails.application.routes.draw do
mount Rswag::Ui::Engine => '/api-docs'
mount Rswag::Api::Engine => '/api-docs'

post "sign_in", to: "sessions#create"
delete "sign_out", to: "sessions#destroy"
post "sign_up", to: "registrations#create"

resources :sessions, only: [:index, :show, :destroy]

resource :password, only: [:edit, :update]

resources :items, only: [:index, :show, :create, :update, :destroy]

resources :pairings, only: [:index, :show, :create, :destroy]

namespace :identity do
resource :email, only: [:edit, :update]
resource :email_verification, only: [:show, :create]
resource :password_reset, only: [:new, :edit, :create, :update]
end

get '/auth/auth0/callback' => 'auth0#callback'
get '/auth/failure' => 'auth0#failure'
get '/auth/logout' => 'auth0#logout'
Expand Down
3 changes: 1 addition & 2 deletions pairings-api/config/routes/sidekiq.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
require 'sidekiq/web'
require "sidekiq-scheduler/web"

Sidekiq::Web.use(Rack::Auth::Basic) do |username, password|
ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), Digest::SHA256.hexdigest(ENV["SIDEKIQ_USERNAME"])) &
ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), Digest::SHA256.hexdigest(ENV["SIDEKIQ_PASSWORD"]))
end

mount Sidekiq::Web => "admins/sidekiq"
mount Sidekiq::Web => "admin/sidekiq"
32 changes: 32 additions & 0 deletions pairings-api/spec/jobs/pairing_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
require 'rails_helper'

RSpec.describe PairingJob, type: :job do
describe '#perform' do
let(:user) { create(:user) }
let!(:item) { create(:item, :with_image, user: user) }

subject { described_class.perform_now(item, user) }

cassette_name = 'pairing_service/valid_response'

it 'creates a second item and a pairing', vcr: { cassette_name: cassette_name } do
expect { subject }.to change(Item, :count).by(1).and change(Pairing, :count).by(1)

expect(AttachImageJob).to have_been_enqueued
end

context 'when creation fails', vcr: { cassette_name: cassette_name } do
before do
allow(item).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new)
end

it 'does not create items or pairings' do
expect(Item.count).to eq(1)
expect(Pairing.count).to eq(0)
expect { subject }.to raise_error(PairingJob::PairingJobError)

expect(AttachImageJob).not_to have_been_enqueued
end
end
end
end
4 changes: 4 additions & 0 deletions pairings-api/spec/rails_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
FileUtils.rm_rf(ActiveStorage::Blob.service.root)
end

config.before(:each) do
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
end

config.include FactoryBot::Syntax::Methods

# If you're not using ActiveRecord, or you'd prefer not to run each of your
Expand Down
Loading