indentlabs/notebook

View on GitHub
app/controllers/basil_controller.rb

Summary

Maintainability
F
4 days
Test Coverage
class BasilController < ApplicationController
  before_action :authenticate_user!, except: [:complete_commission, :about, :stats, :jam, :queue_jam_job, :commission_info]

  before_action :require_admin_access, only: [:review], unless: -> { Rails.env.development? }

  def index
    disabled_content_types = [Universe]

    @enabled_content_types = BasilService::ENABLED_PAGE_TYPES

    @content_type = params[:content_type].try(:humanize) || 'Character'
    if @content_type.present?
      if !@enabled_content_types.include?(@content_type)
        return raise "Invalid content type: #{params[:content_type]}"
      end

      @content = @current_user_content.fetch(@content_type, []).sort_by(&:name)
    end

    @generated_images_count = current_user.basil_commissions.with_deleted.count
  end

  def content
    # Fetch the content page from our already-queried cache of current user content
    @content_type = params[:content_type].humanize
    @content      = @current_user_content[@content_type].detect do |page|
      page.id == params[:id].to_i
    end
    raise "No content found for #{params[:content_type]} with ID #{params[:id]} for user #{current_user.id}" if @content.nil?

    # Fetch any existing Basil configurations/guidance for this character
    @guidance   = BasilFieldGuidance.find_or_initialize_by(
      entity_type: @content.page_type,
      entity_id:   @content.id,
      user:        current_user
    ).try(:guidance)
    @guidance ||= {}

    # Fetch all the related fields for this content type and their values
    # and format them into an array of [field, value] pairs to pass to the view
    @relevant_fields = []
    
    # TODO: either move this to the default field template (metadata for each field) or to a BasilService
    case @content_type
    when 'Character'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Gender')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Age')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Looks', 'Appearance'])

    when 'Location'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Name')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Geography', 'Area')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Geography', 'Climate')

    when 'Item'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Name')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Item Type')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Looks', 'Appearance'])
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Abilities', 'Magical effects')

    when 'Building'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Name')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of building')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Design'])

    when 'Condition'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of condition')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Effects', 'Symptoms')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Effects', 'Visual effects')

    when 'Continent'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Geography', 'Area')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Geography', 'Shape')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Geography', 'Topography')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Geography', 'Bodies of water')

    when 'Country'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Geography', 'Area')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Geography', 'Climate')

    when 'Creature'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of creature')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Looks'])
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Traits', 'Method of attack')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Traits', 'Methods of defense')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Comparisons', 'Similar creatures')

    when 'Deity'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Appearance'])
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Symbolism', 'Elements')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Symbolism', 'Symbols')

    when 'Flora'
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Appearance'])
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')

    when 'Food'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of food')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Recipe', 'Ingredients')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Recipe', 'Cooking method')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Recipe', 'Color')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Recipe', 'Size')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Eating', 'Serving')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Eating', 'Texture')

    when 'Government'
      # DISABLE UNTIL WE HAVE A VISION OF WHAT TO GENERATE
      # but yolo lets see what we get with the below
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Structure'])

    when 'Group'
      # PROBABLY NEEDS TEXTUAL INVERSION ON MEMBERS

    when 'Job'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of job')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')

    when 'Landmark'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of landmark')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Appearance'])
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')

    when 'Language'
      # DISABLE UNTIL WE HAVE A VISION OF WHAT TO GENERATE

    when 'Lore'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Content', 'Genre')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Content', 'Tone')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Culture', 'Time period')
      # TODO textual inversion of any linked pages
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'About', 'Subjects')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')

    when 'Magic'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Name')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of magic')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Appearance'])

    when 'Planet'
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Geography'])
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Astral', 'Moons')

    when 'Race'
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Looks'])

    when 'Religion'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Beliefs', 'Places of worship')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Beliefs', 'Worship services')

    when 'Scene'
      # TODO hold off until we can use textual inversion of members + action + location

    when 'School'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of school')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Identity', 'Colors')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')

    when 'Sport'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Setup', 'Play area')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Setup', 'Equipment')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Setup', 'Number of players')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Setup', 'Scoring')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Culture', 'Uniforms')

    when 'Technology'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Appearance'])

    when 'Town'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Description')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Layout'])

    when 'Tradition'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of tradition')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Celebrations', 'Activities')
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Celebrations', 'Symbolism')

    when 'Vehicle'
      @relevant_fields.push BasilService.include_specific_field(current_user, @content, 'Overview', 'Type of vehicle')
      @relevant_fields.push *BasilService.include_all_fields_in_category(current_user, @content, ['Looks'])

    end
    @relevant_fields.compact!

    # Finally, cache some state we can reference in the view
    @commissions = BasilCommission.where(entity_type: @content.page_type, entity_id: @content.id)
                                  .where(saved_at: nil)
                                  .order('id DESC')
                                  .limit(10)
                                  .includes(:basil_feedbacks, :image_blob)
    @in_progress_commissions = @commissions.select { |c| c.completed_at.nil? }
    @generated_images_count  = current_user.basil_commissions.with_deleted.count

    @can_request_another     = current_user.on_premium_plan? || @generated_images_count < BasilService::FREE_IMAGE_LIMIT
    @can_request_another     = @can_request_another && @in_progress_commissions.count < BasilService::MAX_JOB_QUEUE_SIZE
  end

  def about
  end

  def jam
    @recent_commissions = BasilCommission.where(entity_id: nil).order('id DESC').limit(24)
    @total_count = BasilCommission.where(entity_id: nil).count

    # For generating pie charts
    @all_commissions = BasilCommission.where(entity_id: nil)
  end

  def queue_jam_job
    created_prompt = [
      jam_params[:age],
      *jam_params[:features]
    ].compact.join(', ')

    # Create our commission, then redirect back to preview it
    BasilCommission.create!(
      user:   current_user,
      entity: nil,
      prompt: created_prompt,
      job_id: SecureRandom.uuid,
      style:  ["realistic", "realistic2", "realistic3", "painting", "painting2", "painting3"].sample,
      final_settings: jam_params
    )

    redirect_back(fallback_location: basil_jam_path, notice: "#{jam_params.fetch(:name, '').presence || 'Your character'} will be visualized shortly. Find them on this page!")
  end

  def commission_info
    @commission = BasilCommission.find_by(job_id: params[:jobid])
    raise "No BasilCommission with ID #{params[:jobid]}" if @commission.nil?

    render json: {
      image_url: @commission.image.url,
      user_id: @commission.user_id,
      prompt: @commission.prompt,
      job_id: @commission.job_id,
      created_at: @commission.created_at,
      updated_at: @commission.updated_at,
      completed_at: @commission.completed_at,
      style: @commission.style,
      final_settings: @commission.final_settings,
      cached_seconds_taken: @commission.cached_seconds_taken,
    }
  end

  def stats
    @commissions = BasilCommission.all.with_deleted

    @queued = BasilCommission.where(completed_at: nil)
    @completed = BasilCommission.where.not(completed_at: nil).with_deleted

    @average_wait_time = @completed.where('completed_at > ?', 24.hours.ago)
                                   .average(:cached_seconds_taken) || 0
    @seconds_over_time = @completed.where('completed_at > ?', 24.hours.ago)
                                   .group_by { |c| ((c.cached_seconds_taken || 0) / 60).round }
                                   .map { |minutes, list| [minutes, list.count] }

    # Projected date, at our current rate, to reach 1,000,000 images
    commission_counts_per_day   = @commissions.group_by_day(:completed_at).values
    @average_commissions_per_day = commission_counts_per_day.sum(0.0) / commission_counts_per_day.count
    commissions_left            = 1_000_000 - @commissions.count
    @days_til_1_million_commissions = commissions_left / @average_commissions_per_day

    # Feedback today
    @feedback_today = BasilFeedback.where('updated_at > ?', 24.hours.ago)
                                   .order(:score_adjustment)
                                   .group(:score_adjustment)
                                   .count
    total = @feedback_today.values.sum
    @emoji_counts_today = @feedback_today.map do |score, count|
      emoji = case score
        when -2 then "Very Bad :'("
        when -1 then "Bad :("
        when  0 then "Meh :|"
        when  1 then "Good :)"
        when  2 then "Very Good :D"
        when  3 then "Lovely! <3"
      end
      [emoji, (count / total.to_f * 100).round(1)]
    end

    # Feedback all time
    @feedback_before_today = BasilFeedback.where('updated_at < ?', 24.hours.ago)
                                      .order(:score_adjustment)
                                      .group(:score_adjustment)
                                      .count
    days_since_start = (Date.current - BasilFeedback.minimum(:updated_at).to_date)
    days_since_start = 1 if days_since_start.zero? # no dividing by 0 lol

    total = @feedback_before_today.values.sum
    @emoji_counts_all_time = @feedback_before_today.map do |score, count|
      emoji = case score
        when -2 then "Very Bad :'("
        when -1 then "Bad :("
        when  0 then "Meh :|"
        when  1 then "Good :)"
        when  2 then "Very Good :D"
        when  3 then "Lovely! <3"
      end

      [emoji, (count / total.to_f * 100).round(1)]
    end

    active_styles = [
      BasilService.enabled_styles_for('Character'),
      BasilService.enabled_styles_for('Location'),
      # Also include anything we specifically want to track for now :)
      'painting2', 'painting3', 'anime'
    ].flatten.compact.uniq

    @total_score_per_style = BasilCommission.with_deleted
                                            .where(style: active_styles)
                                            .joins(:basil_feedbacks)
                                            .group(:style)
                                            .sum(:score_adjustment)
                                            .map { |style, average| [style, average.round(1)] }
                                            .sort_by(&:second)
                                            .reverse
    @average_score_per_style = BasilCommission.with_deleted
                                              .where(style: active_styles)
                                              .joins(:basil_feedbacks)
                                              .group(:style)
                                              .average(:score_adjustment)
                                              .map { |style, average| [style, average.round(1)] }
                                              .sort_by(&:second)
                                              .reverse

    @average_score_per_page_type = BasilCommission.with_deleted
                                                  .where.not(completed_at: nil)
                                                  .joins(:basil_feedbacks)
                                                  .group(:entity_type)
                                                  .average(:score_adjustment)
                                                  .map { |k, v| [k, (v * 100).round(1)] }.to_h

    # queue size (total commissions - completed commissions)
    # average time to complete today / this week
    # commissions per day bar chart
    # count(average time to complete) bar chart
  end

  def page_stats
    @page_type = params[:page_type]
    # TODO verify page_type is valid

    @commissions = BasilCommission.where(entity_type: @page_type)

    # Feedback today
    @feedback_today = BasilFeedback.where('updated_at > ?', 24.hours.ago)
                                   .where(basil_commission_id: @commissions.pluck(:id))
                                   .order(:score_adjustment)
                                   .group(:score_adjustment)
                                   .count
    total = @feedback_today.values.sum
    @emoji_counts_today = @feedback_today.map do |score, count|
      emoji = case score
        when -2 then "Very Bad :'("
        when -1 then "Bad :("
        when  0 then "Meh :|"
        when  1 then "Good :)"
        when  2 then "Very Good :D"
        when  3 then "Lovely! <3"
      end
      [emoji, (count / total.to_f * 100).round(1)]
    end

    # Feedback all time
    @feedback_before_today = BasilFeedback.where('updated_at < ?', 24.hours.ago)
                                          .where(basil_commission_id: @commissions.pluck(:id))
                                          .order(:score_adjustment)
                                          .group(:score_adjustment)
                                          .count
    days_since_start = (Date.current - BasilFeedback.minimum(:updated_at).to_date)
    days_since_start = 1 if days_since_start.zero? # no dividing by 0 lol

    total = @feedback_before_today.values.sum
    @emoji_counts_all_time = @feedback_before_today.map do |score, count|
      emoji = case score
        when -2 then "Very Bad :'("
        when -1 then "Bad :("
        when  0 then "Meh :|"
        when  1 then "Good :)"
        when  2 then "Very Good :D"
        when  3 then "Lovely! <3"
      end

      [emoji, (count / total.to_f * 100).round(1)]
    end

    active_styles = [
      BasilService.enabled_styles_for(@page_type),
      BasilService.experimental_styles_for(@page_type),
    ].flatten.compact.uniq

    @total_score_per_style = @commissions.where(style: active_styles)
                                              .joins(:basil_feedbacks)
                                              .group(:style)
                                              .sum(:score_adjustment)
                                              .map { |style, average| [style, average.round(1)] }
                                              .sort_by(&:second)
                                              .reverse
    @average_score_per_style = @commissions.where(style: active_styles)
                                              .joins(:basil_feedbacks)
                                              .group(:style)
                                              .average(:score_adjustment)
                                              .map { |style, average| [style, average.round(1)] }
                                              .sort_by(&:second)
                                              .reverse

    @average_score_per_page_type = @commissions.where.not(completed_at: nil)
                                                  .joins(:basil_feedbacks)
                                                  .group(:entity_type)
                                                  .average(:score_adjustment)
                                                  .map { |k, v| [k, (v * 100).round(1)] }.to_h

    # # queue size (total commissions - completed commissions)
    # # average time to complete today / this week
    # # commissions per day bar chart
    # # count(average time to complete) bar chart
  end

  def review
    @recent_commissions = BasilCommission.all.includes(:entity, :user).order('id DESC').limit(100)

    @commissions_per_user_id = BasilCommission.with_deleted.where('created_at > ?', 48.hours.ago).group(:user_id).order('count_all DESC').limit(5).count
    @unique_users_generating_count = BasilCommission.with_deleted.where('created_at > ?', 48.hours.ago).group(:user_id).count

    @current_queue_items = BasilCommission.where(completed_at: nil).order('created_at ASC')
  end

  def commission
    @generated_images_count  = current_user.basil_commissions.with_deleted.count
    if !current_user.on_premium_plan? && @generated_images_count > BasilService::FREE_IMAGE_LIMIT
      redirect_back fallback_location: basil_path, notice: "You've reached your free image limit. Please upgrade to generate more images."
      return
    end

    # Fetch the related content
    @content = @current_user_content[commission_params.fetch(:entity_type)]
                  .find { |c| c.id == commission_params.fetch(:entity_id).to_i }
    return raise "Invalid content commission params" if @content.nil?

    current_queue_size = current_user.basil_commissions.where(completed_at: nil).where(entity: @content).count
    if current_queue_size >= BasilService::MAX_JOB_QUEUE_SIZE
      redirect_back fallback_location: basil_path, notice: "You can only have #{BasilService::MAX_JOB_QUEUE_SIZE} commissions per page in progress at a time. Please wait for one to complete before requesting another."
      return
    end

    # Before creating the prompt, do a little config to tweak things to work well :)
    labels_to_omit_label_text = [
      "Name",
      "Identifying Marks",
      "Type",
      "Description",
      "Body Type",
      "Item type",
      "Type of food"
    ].map(&:downcase)
    field_importance_multipliers = {
      'hair':        1.15,
      'hair color':  1.55,
      'hair style':  1.10,
      'skin tone':   1.05,
      'race':        1.10,
      'eye color':   1.05,
      'gender':      1.15,
      'description': 1.00,
      'item type':   1.55,
      'type':        1.15,
      'type of building':  1.25,
      'type of condition': 1.25,
      'type of food':      1.25,
      'type of landmark':  1.25,
      'type of magic':     1.25,
      'type of school':    1.25,
      'type of vehicle':   1.25,
      'type of creature':  1.25
    }
    label_value_pairs_to_skip_entirely = [
      ['race', 'human']
    ]
    value_suffix_for_numerical_fields = {
      'age': ' years old'
    }

    # Prepare our prompt components
    prompt_components = []
    commission_params.fetch(:field).each do |field_id, field_data|
      label      = field_data[:label].strip
      value      = field_data[:value].gsub(',',  '')
                                     .gsub("\r", '')
                                     .gsub('(',  '')
                                     .gsub(')',  '')
                                     .gsub("\n", ' ')
                                     .strip
      importance = field_data[:importance].to_f

      # Field skips
      next if label_value_pairs_to_skip_entirely.include?([label.downcase, value.downcase])

      # Do any per-field manipulations
      importance *= field_importance_multipliers[label.downcase.to_sym]      if field_importance_multipliers.key?(label.downcase.to_sym)
      value      += value_suffix_for_numerical_fields[label.downcase.to_sym] if value_suffix_for_numerical_fields.key?(label.downcase.to_sym)
      label       = ''                                                       if labels_to_omit_label_text.include?(label.downcase)

      # Finally, cut down on any unnecessary precision to save more tokens
      importance = importance.round(2)

      component_text = "#{value} #{label}".strip
      if importance == 1.0
        # If the importance is exactly 1, we can omit the parentheses and save a few tokens, since the
        # default attention importance is 1.
        prompt_components.push "#{component_text}"
      elsif importance != 0
        # If the importance isn't 1 (default) or 0 (not included), we want to specify it in the prompt.
        prompt_components.push "(#{component_text}:#{importance})"
      end
    end

    # Build a prompt for Basil from the component parts
    prompt = prompt_components.join(', ')

    # Save our field weights as the latest guidance also
    guidance = BasilFieldGuidance.find_or_initialize_by(entity_type: @content.page_type,
                                                        entity_id:   @content.id,
                                                        user:        current_user)
    guidance_data = commission_params.fetch(:field)
                                     .transform_values { |data| data[:importance].to_f }
                                     .to_h
    guidance.update(guidance: guidance_data)

    BasilCommission.create!(
      user:        current_user,
      entity_type: @content.page_type,
      entity_id:   @content.id,
      prompt:      prompt,
      style:       commission_params.fetch(:style, 'realistic'),
      job_id:      SecureRandom.uuid
    )

    redirect_to basil_content_path(@content.page_type, @content.id)
  end

  def complete_commission
    commission = BasilCommission.find_by(job_id: params[:jobid])
    raise "Tried to complete commission with invalid job ID #{params[:jobid]}" if commission.nil?
    
    merged_settings = commission.final_settings || {}
    commission.update!(completed_at:   DateTime.current,
                       final_settings: merged_settings.merge(JSON.parse(params.fetch(:settings, "{}"))))

    # Attach the image in S3 to our `image` ActiveStorage relation
    key    = "job-#{params[:jobid]}.png"
    s3     = Aws::S3::Resource.new(region: "us-east-1")
    obj    = s3.bucket("basil-commissions").object(key)
    params = { 
      filename:     obj.key, 
      content_type: obj.content_type, # binary/octet-stream but we want image/png
      byte_size:    obj.size, 
      checksum:     obj.etag.gsub('"',"")
    }
    blob = ActiveStorage::Blob.create_before_direct_upload!(**params)
    blob.key = key
    blob.service_name = :amazon_basil
    blob.save!

    commission.update(image: blob.signed_id)
    commission.cache_after_complete!

    render json: { success: true }
  end

  def feedback
    commission = BasilCommission.find_by(job_id: params[:jobid])
    score_adjustment = params[:basil_feedback][:score_adjustment].to_i
    score_adjustment = score_adjustment.clamp(-3, 3)

    feedback = commission.basil_feedbacks.find_or_initialize_by(user: current_user)
    feedback.update!(score_adjustment: score_adjustment)
  end

  def help_rate
    @reviewed_commission_count = BasilFeedback.where(user: current_user).where.not(score_adjustment: nil).count

    @reviewed_commission_ids = BasilFeedback.where(user: current_user)
    if params.key?(:rating)
      @reviewed_commission_ids = @reviewed_commission_ids.where(score_adjustment: params[:rating].to_i)

      @commissions = BasilCommission.where(id: @reviewed_commission_ids.pluck(:basil_commission_id))
        .where.not(completed_at: nil)
        .where(user: current_user)
        .where.not(entity_type: nil, entity_id: nil)
        .order(created_at: :desc)
        .limit(50)
        .includes(:entity)
        .order(completed_at: :desc)
    else
      # Unreviewed commissions
      @reviewed_commission_ids = @reviewed_commission_ids.where(score_adjustment: nil)

      @commissions = BasilCommission.where.not(id: @reviewed_commission_ids)
        .where.not(completed_at: nil)
        .where(user: current_user)
        .where.not(entity_type: nil, entity_id: nil)
        .order(created_at: :desc)
        .limit(50)
        .includes(:entity)
        .order(completed_at: :desc)
    end
  end

  def save
    @commission = BasilCommission.find_by(
      id:   params[:id],
      user: current_user
    )
    @commission.update(saved_at: DateTime.current)
    render json: { success: true }, status: 200
  end

  def delete
    @commission = BasilCommission.find_by(
      id:   params[:id],
      user: current_user
    )
    @commission.destroy!
    render json: { success: true }, status: 200
  end

  private

  def commission_params
    params.require(:basil_commission).permit(:style, :entity_type, :entity_id, field: {})
  end

  def jam_params
    params.require(:commission).permit(:name, :age, features: [])
  end
end