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 disabled_content_types = [Universe] @enabled_content_types = [ Character, Location, Item, # Building, Condition, Continent, Country, # Creature, Deity, Flora, Food, Government, Group, Job, Landmark, Language, # Lore, Magic, Planet, Race, Religion, Scene, School, Sport, Technology, # Town, Tradition, Vehicle ].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 = [] # 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']) 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', '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) .order('id DESC') .limit(10) .includes(:basil_feedbacks, :image_blob) @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", "Body Type", "Item type", ].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.15, 'type of landmark': 1.25, 'type of magic': 1.25, 'type of school': 1.25, 'type of vehicle': 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]) return if commission.nil? commission.update(completed_at: DateTime.current, final_settings: JSON.parse(params[: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 # 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 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 end