class BasilController < ApplicationController before_action :authenticate_user!, except: [:complete_commission, :about, :stats] before_action :require_admin_access, only: [:review], unless: -> { Rails.env.development? } def index @enabled_content_types = [Character, Location].map(&:name) @content_type = params[:content_type].try(:humanize) || 'Character' if @content_type.nil? || !@enabled_content_types.include?(@content_type) return raise "Invalid content type: #{params[:content_type]}" end @content = @current_user_content[@content_type].sort_by(&:name) 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: @content, 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 = [] 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') 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) .order('id DESC') .limit(20) .includes(:basil_feedbacks) @in_progress_commissions = @commissions.select { |c| c.completed_at.nil? } @can_request_another = @in_progress_commissions.count < 3 end def character @character = current_user.characters.find(params[:id]) @guidance = BasilFieldGuidance.find_or_initialize_by(entity: @character, user: current_user).try(:guidance) @guidance ||= {} category_ids = AttributeCategory.where( user_id: current_user.id, entity_type: 'character', label: ['Looks', 'Appearance'] ).pluck(:id) @appearance_fields = AttributeField.where(attribute_category_id: category_ids) @attributes = Attribute.where( attribute_field_id: @appearance_fields.pluck(:id), entity_id: @character.id, entity_type: 'Character' ) @commissions = BasilCommission.where(entity_type: 'Character', entity_id: @character.id) .order('id DESC') .limit(20) .includes(:basil_feedbacks) @in_progress_commissions = BasilCommission.where(entity_type: 'Character', entity_id: @character.id, completed_at: nil) @can_request_another = @in_progress_commissions.count < 3 end def about end def stats @commissions = BasilCommission.all @queued = BasilCommission.where(completed_at: nil) @completed = BasilCommission.where.not(completed_at: nil) @average_wait_time = @completed.where('completed_at > ?', 24.hours.ago) .average(:cached_seconds_taken) @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.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.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.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) end def commission # 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? # Before creating the prompt, do a little config to tweak things to work well :) labels_to_omit_label_text = [ "Name", "Identifying Marks", "Type", "Description" ].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 } 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]) return if commission.nil? commission.update(completed_at: DateTime.current, final_settings: JSON.parse(params[:settings])) commission.cache_after_complete! # TODO: we should attach the S3 object to the commission.image attachment # but I dunno how to do that yet. See broken attempts below. # s3 = Aws::S3::Resource.new(region: "us-east-1") # obj = s3.bucket("basil-characters").object("job-#{params[:jobid]}.png") # blob_params = { # filename: File.basename(obj.key), # content_type: obj.content_type, # byte_size: obj.size, # checksum: obj.etag.gsub('"',"") # } # raise "wow" # blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_params) # # By default, the blob's key (S3 key, in this case) a secure (random) token # # However, since the file is already on S3, we need to change the # # key to match our file on S3 # blob.update_attribute :key, obj.key # raise params.inspect 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 # Commissions without feedback: @reviewed_commission_ids = BasilFeedback.where(user: current_user) .pluck(:basil_commission_id) @commissions = BasilCommission.where.not(id: @reviewed_commission_ids) .where.not(completed_at: nil) .where(user: current_user) .order(created_at: :desc) .limit(50) .includes(:entity) .shuffle end private def commission_params params.require(:basil_commission).permit(:style, :entity_type, :entity_id, field: {}) end end