diff --git a/Gemfile b/Gemfile index c687986..39f7147 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,8 @@ gem 'ruby-openai' gem 'activegraph', '11.5.0.beta.3' gem 'neo4j-ruby-driver' gem 'yard' +gem 'csv' +gem 'ostruct' # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 7.2.1" diff --git a/Gemfile.lock b/Gemfile.lock index 7c7ff74..8a64e4d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,6 +109,7 @@ GEM fiber-local json crass (1.0.6) + csv (3.3.0) date (3.3.4) debug (1.9.2) irb (~> 1.10) @@ -185,6 +186,7 @@ GEM nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) orm_adapter (0.5.0) + ostruct (0.6.0) pg (1.5.7) pry (0.14.2) coderay (~> 1.1) @@ -334,10 +336,12 @@ DEPENDENCIES activegraph (= 11.5.0.beta.3) bootsnap capybara + csv debug dotenv faker (~> 3.4, >= 3.4.2) neo4j-ruby-driver + ostruct pg pry puma (>= 5.0) @@ -356,7 +360,7 @@ DEPENDENCIES yard RUBY VERSION - ruby 3.2.2p53 + ruby 3.3.5p100 BUNDLED WITH 2.5.6 diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 039afb2..678de7d 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -8,3 +8,55 @@ --icon-zoom: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTIwMHB0IiBoZWlnaHQ9IjEyMDBwdCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTIwMCAxMjAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPHBhdGggZD0ibTExMTUuOCA5NzIuOC0yMzUuMzgtMjM1LjMxYzQyLjgzNi02OC42OTEgNjcuOTY1LTE0OS40OSA2Ny45NjUtMjM2LjE0IDAtMjQ2LjI5LTIwMC41MS00NDYuOC00NDYuOTUtNDQ2LjhzLTQ0Ni44NyAyMDAuNTEtNDQ2Ljg3IDQ0Ni44YzAgMjQ2LjUxIDIwMC40NCA0NDcuMDkgNDQ2Ljg3IDQ0Ny4wOSA4Ni42NTYgMCAxNjcuNDItMjUuMTk5IDIzNi02Ny45NjVsMjM1LjQ1IDIzNS4zOGMxOS43NDYgMTkuNjAyIDQ1LjYwMiAyOS42MDIgNzEuNjAyIDI5LjYwMiAyNS43MTEgMCA1MS42MzctMTAgNzEuMzA5LTI5LjYwMiAzOS41MjctMzkuNDg4IDM5LjUyNy0xMDMuNDkgMC0xNDMuMDV6bS05MzQuOC00NzEuNDZjMC0xNzYuNTUgMTQzLjc1LTMyMC40IDMyMC40Ny0zMjAuNCAxNzYuODQgMCAzMjAuNTggMTQzLjgyIDMyMC41OCAzMjAuNCAwIDE3Ni43Ni0xNDMuNzUgMzIwLjY2LTMyMC41OCAzMjAuNjYtMTc2LjczIDAtMzIwLjQ3LTE0My44OS0zMjAuNDctMzIwLjY2eiIgZmlsbD0iIzAwMDAwMCIvPgo8L3N2Zz4=); --icon-zoom-light: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTIwMHB0IiBoZWlnaHQ9IjEyMDBwdCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMTIwMCAxMjAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPHBhdGggZD0ibTExMTUuOCA5NzIuOC0yMzUuMzgtMjM1LjMxYzQyLjgzNi02OC42OTEgNjcuOTY1LTE0OS40OSA2Ny45NjUtMjM2LjE0IDAtMjQ2LjI5LTIwMC41MS00NDYuOC00NDYuOTUtNDQ2LjhzLTQ0Ni44NyAyMDAuNTEtNDQ2Ljg3IDQ0Ni44YzAgMjQ2LjUxIDIwMC40NCA0NDcuMDkgNDQ2Ljg3IDQ0Ny4wOSA4Ni42NTYgMCAxNjcuNDItMjUuMTk5IDIzNi02Ny45NjVsMjM1LjQ1IDIzNS4zOGMxOS43NDYgMTkuNjAyIDQ1LjYwMiAyOS42MDIgNzEuNjAyIDI5LjYwMiAyNS43MTEgMCA1MS42MzctMTAgNzEuMzA5LTI5LjYwMiAzOS41MjctMzkuNDg4IDM5LjUyNy0xMDMuNDkgMC0xNDMuMDV6bS05MzQuOC00NzEuNDZjMC0xNzYuNTUgMTQzLjc1LTMyMC40IDMyMC40Ny0zMjAuNCAxNzYuODQgMCAzMjAuNTggMTQzLjgyIDMyMC41OCAzMjAuNCAwIDE3Ni43Ni0xNDMuNzUgMzIwLjY2LTMyMC41OCAzMjAuNjYtMTc2LjczIDAtMzIwLjQ3LTE0My44OS0zMjAuNDctMzIwLjY2eiIgZmlsbD0iI2ZmZiIvPgo8L3N2Zz4K); } + +.throbber, +.throbber div { + box-sizing: border-box; +} +.throbber { + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} +.throbber div { + position: absolute; + border: 4px solid var(--color-green); + opacity: 1; + border-radius: 50%; + animation: throbber 2s cubic-bezier(0, 0.2, 0.8, 1) infinite; +} +.throbber div:nth-child(2) { + animation-delay: -0.5s; +} +@keyframes throbber { + 0% { + top: 36px; + left: 36px; + width: 8px; + height: 8px; + opacity: 0; + } + 4.9% { + top: 36px; + left: 36px; + width: 8px; + height: 8px; + opacity: 0; + } + 5% { + top: 36px; + left: 36px; + width: 8px; + height: 8px; + opacity: 1; + } + 100% { + top: 0; + left: 0; + width: 80px; + height: 80px; + opacity: 0; + } +} + diff --git a/app/assets/stylesheets/styles.css b/app/assets/stylesheets/styles.css index 7cc3f72..20732c6 100644 --- a/app/assets/stylesheets/styles.css +++ b/app/assets/stylesheets/styles.css @@ -76,7 +76,7 @@ ul, ol { list-style-position: inside; - margin: 1.5rem 0 1.5rem 1.5rem; + margin: 0 0 1.5rem 1.5rem; } em { diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index e571777..e255b91 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -4,6 +4,7 @@ class CategoriesController < ApplicationController def index @section_name = @question.label + @context = @question.context @categories = Category.where(context: @question.context.name).order(:name) @enqueued_at = params[:enqueued_at].present? ? Time.at(params[:enqueued_at].to_i).strftime("%T %Z") : nil end @@ -14,7 +15,8 @@ def show end def new - @category = Category.new(name: "New Category", context: @question.context.name) + @category = Category.new(name: "New Category", context: @question.context.name) + @context = @question.context end def create diff --git a/app/controllers/codebooks_controller.rb b/app/controllers/codebooks_controller.rb index 7890ec4..306e126 100644 --- a/app/controllers/codebooks_controller.rb +++ b/app/controllers/codebooks_controller.rb @@ -35,4 +35,16 @@ def show end + def enqueue_category_suggestions + question = Question.find(params[:codebook_id]) + context = question.context + CategorySuggestionsJob.perform_async(context.id) + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace("frame-suggestions", partial: "/categories/suggestions", locals: { context: context, question: question, enqueued: params[:enqueued] }) + end + end + + end + end diff --git a/app/jobs/category_suggestions_job.rb b/app/jobs/category_suggestions_job.rb new file mode 100644 index 0000000..9a91e3b --- /dev/null +++ b/app/jobs/category_suggestions_job.rb @@ -0,0 +1,15 @@ +# This background job performs the Category derivation process. +class CategorySuggestionsJob + + include Sidekiq::Job + + queue_as :default + + def perform(context_id) + Rails.logger.info("CategorySuggestionsJob running with context id #{context_id}") + return unless context = Context.find(context_id) + context.suggest_categories + context.update_attribute(:suggestions_updated_at, DateTime.now) + end + +end diff --git a/app/models/context.rb b/app/models/context.rb index f054b60..513c4a4 100644 --- a/app/models/context.rb +++ b/app/models/context.rb @@ -7,4 +7,10 @@ class Context < ApplicationRecord has_many :questions + def suggest_categories + update_attribute(:suggested_categories, []) + categories = Services::SuggestCategories.perform(self.id).map{|category| category['category'] } + update_attribute(:suggested_categories, categories) + end + end diff --git a/app/models/services/suggest_categories.rb b/app/models/services/suggest_categories.rb index b56e4af..0c91e32 100644 --- a/app/models/services/suggest_categories.rb +++ b/app/models/services/suggest_categories.rb @@ -1,7 +1,7 @@ module Services class SuggestCategories - attr_accessor :text + attr_accessor :context # This is the prompt sent to the selected AI agent to provide instructions on category derivision. PROMPT = %{ @@ -19,21 +19,25 @@ class SuggestCategories ] } - The list of codes is: + The array of codes is: } - def self.perform(text) - new(text).perform + def self.perform(context_id) + new(context_id).perform end - def initialize(text) - @text = text + def initialize(context_id) + @context = Context.find(context_id) end # Uses the OpenAI client to pass the prompt and text through the API for sentiment analysis. def perform - return false unless text.present? - response = Clients::OpenAi.request("#{PROMPT} #{text}") + return false unless context.present? + codes = Code.where(context: context.name).map(&:name) + + response = Clients::OpenAi.request("#{PROMPT} #{codes}") + # response = { "categories" => [ { "category" => "divisions" }, { "category" => "third space" }, { "category" => "intergenerational" } ] } + return false unless response['categories'].present? return response['categories'] end diff --git a/app/views/categories/_suggestions.html.erb b/app/views/categories/_suggestions.html.erb new file mode 100644 index 0000000..ed50ebd --- /dev/null +++ b/app/views/categories/_suggestions.html.erb @@ -0,0 +1,23 @@ +<%= turbo_frame_tag "frame-suggestions" do %> + + <% if enqueued && DateTime.parse(enqueued) > context.suggestions_updated_at %> +
+ + <% else %> + + <%= button_to "Get Suggestions", codebook_enqueue_category_suggestions_path(question.id, params: { enqueued: DateTime.now }, method: :put, form: { data: { turbo: true, turbo_stream: true } } ) %> + <% end %> + +<% end %> diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index f5dbcd8..1e5d260 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -16,10 +16,6 @@

Suggested Categories:

-<% if @enqueued_at %> -

Category suggestions were requested at <%= @enqueued_at %>. Please allow up to 2 minutes for the process to complete. You can reload the page to monitor progress.

-<% else %> - <%= button_to "Get Suggestions", codebook_enqueue_categories_path(@question.id, params: { enqueued: true }, method: :put ) %> -<% end %> +<%= render partial: "suggestions", locals: { context: @context, question: @question, enqueued: nil } %> <%= render partial: "/shared/filtering" %> diff --git a/app/views/categories/new.html.erb b/app/views/categories/new.html.erb index f2c8fb9..550568d 100644 --- a/app/views/categories/new.html.erb +++ b/app/views/categories/new.html.erb @@ -2,15 +2,32 @@

Add a Category

-<%= form_with model: @category, url: question_categories_path(question_id: @question.id) do |f| %> -
- <%= label :name, "Name" %> - <%= f.text_field :name %> -
-
- <%= label :description, "Description" %> - <%= f.text_area :description %> -
- <%= f.hidden_field :context %> - <%= f.submit "Create" %> -<% end %> +
+ +
+ <%= form_with model: @category, url: question_categories_path(question_id: @question.id) do |f| %> +
+ <%= label :name, "Name" %> + <%= f.text_field :name %> +
+
+ <%= label :description, "Description" %> + <%= f.text_area :description %> +
+ <%= f.hidden_field :context %> + <%= f.submit "Create" %> + <% end %> +
+ + <% if @context.suggested_categories.any? %> +
+

Suggested categories:

+ +
+ <% end %> + +
diff --git a/config/routes.rb b/config/routes.rb index 3e9893a..ae1ada0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,7 +19,7 @@ resources :themes resources :codebooks do - post "enqueue_categories", action: "enqueue_categories" + post "enqueue_category_suggestions", action: "enqueue_category_suggestions" end resources :questions do diff --git a/db/migrate/20241031002325_add_suggested_categories_to_context.rb b/db/migrate/20241031002325_add_suggested_categories_to_context.rb new file mode 100644 index 0000000..9449947 --- /dev/null +++ b/db/migrate/20241031002325_add_suggested_categories_to_context.rb @@ -0,0 +1,6 @@ +class AddSuggestedCategoriesToContext < ActiveRecord::Migration[7.2] + def change + add_column :contexts, :suggested_categories, :string, array: true, default: [] + add_column :contexts, :suggestions_updated_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index c09986e..0200d9f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_10_30_190525) do +ActiveRecord::Schema[7.2].define(version: 2024_10_31_002325) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "plpgsql" @@ -56,6 +56,8 @@ t.string "display_name" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "suggested_categories", default: [], array: true + t.datetime "suggestions_updated_at" end create_table "questions", force: :cascade do |t|