diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..5aa8e0c3 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.3.6 diff --git a/Gemfile b/Gemfile index 7f74aacb..a292e52e 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ gem 'addressable' gem 'anyway_config' gem 'aws-sdk-s3' gem 'bourbon', '~> 4.0.2' +gem 'bcrypt', '~> 3.1.7' gem 'clearance', '~> 2.5.0' gem 'clearance-deprecated_password_strategies' gem 'coffee-rails' @@ -25,7 +26,9 @@ gem 'jbuilder' gem 'jquery-rails', '~> 4.1.1' gem 'jquery-ui-rails', '~> 3.0.1' gem 'magnific-popup-rails' -gem 'nokogiri', '~> 1.18.9' +gem 'nio4r', '>= 2.7.3' +gem 'nokogiri', '~> 1.19' +gem 'ruby-openai' gem 'pg', '~> 1.4.4' gem 'puma' gem 'rack-attack' diff --git a/Gemfile.lock b/Gemfile.lock index 3f32a009..8f2d38eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -181,6 +181,7 @@ GEM email_validator (2.2.4) activemodel erubi (1.13.1) + event_stream_parser (1.0.0) execjs (2.10.0) factory_bot (6.5.1) activesupport (>= 6.1.0) @@ -189,7 +190,16 @@ GEM railties (>= 5.0.0) faker (2.23.0) i18n (>= 1.8.11, < 2) - ffi (1.17.1) + faraday (2.8.1) + base64 + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (3.0.2) + ffi (1.17.3) + ffi (1.17.3-x86_64-darwin) + ffi (1.17.3-x86_64-linux-gnu) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake @@ -266,6 +276,7 @@ GEM mocha (1.2.1) metaclass (~> 0.0.1) msgpack (1.8.0) + multipart-post (2.4.1) net-imap (0.5.12) date net-protocol @@ -277,9 +288,13 @@ GEM net-protocol newrelic_rpm (9.22.0) nio4r (2.7.5) - nokogiri (1.18.9) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nokogiri (1.19.0-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-gnu) + racc (~> 1.4) pg (1.4.6) pp (0.6.2) prettyprint @@ -391,6 +406,11 @@ GEM rspec-support (~> 3.13) rspec-support (3.13.2) ruby-next-core (1.1.2) + ruby-openai (7.3.1) + event_stream_parser (>= 0.3.0, < 2.0.0) + faraday (>= 1) + faraday-multipart (>= 1) + ruby2_keywords (0.0.5) rubyzip (2.4.1) sass (3.7.4) sass-listen (~> 4.0.0) @@ -490,11 +510,15 @@ GEM PLATFORMS ruby + x86_64-darwin-20 + x86_64-linux + x86_64-linux-gnu DEPENDENCIES addressable anyway_config aws-sdk-s3 + bcrypt (~> 3.1.7) bootsnap (>= 1.1.0) bourbon (~> 4.0.2) bourne @@ -525,7 +549,8 @@ DEPENDENCIES listen magnific-popup-rails newrelic_rpm - nokogiri (~> 1.18.9) + nio4r (>= 2.7.3) + nokogiri (~> 1.19) pg (~> 1.4.4) pry pry-nav @@ -541,6 +566,7 @@ DEPENDENCIES redcarpet rollbar rspec-rails (~> 7.0.2) + ruby-openai sassc-rails (~> 2.1) selenium-webdriver sham_rack diff --git a/app/assets/stylesheets/_base-variables.scss b/app/assets/stylesheets/_base-variables.scss index e62a646f..1c017aa7 100644 --- a/app/assets/stylesheets/_base-variables.scss +++ b/app/assets/stylesheets/_base-variables.scss @@ -13,6 +13,15 @@ $button-pink: rgb(252, 103, 147); $blue: rgb(34,122,255); //blue +$green-background: #a7f3d0; +$green-text: #065f46; + +$yellow-background: #fef08a; +$yellow-text: #854d0e; + +$red-background: #fecaca; +$red-text: #7f1d1d; + /*** * Responsiveness Sizes * diff --git a/app/assets/stylesheets/_projects-analyses.scss b/app/assets/stylesheets/_projects-analyses.scss new file mode 100644 index 00000000..d7bf7825 --- /dev/null +++ b/app/assets/stylesheets/_projects-analyses.scss @@ -0,0 +1,45 @@ +// @import "base_variables"; + +body.project-analysis-show { + h1 { + font-size: 24px; + margin-bottom: 16px; + } + + .scores-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + + th, + td { + padding: 8px; + border: 1px solid #ddd; + } + + th { + background-color: #f0f0f0; + text-align: left; + font-weight: bold; + } + + // Define classes for score rows using the color variables + tr.score-row--high { + background-color: $green-background; + color: $green-text; + font-weight: 900; + } + + tr.score-row--medium { + background-color: $yellow-background; + color: $yellow-text; + } + + tr.score-row--low { + background-color: $red-background; + color: $red-text; + } + } + + // Additional styles can be defined here, following your SCSS strategies +} diff --git a/app/assets/stylesheets/_projects-index.scss b/app/assets/stylesheets/_projects-index.scss index ed86a095..64bca324 100644 --- a/app/assets/stylesheets/_projects-index.scss +++ b/app/assets/stylesheets/_projects-index.scss @@ -459,6 +459,17 @@ body.projects-index { a.project-actions-toggle { font-size: 1.1em; } + + a.auto-awesome { + @include button(rgb(240,240,240)); + float: right; + margin-left: 10px; + text-decoration: none; + + &.auto-awesome:hover { + @include button($pink); + } + } } .contact { diff --git a/app/assets/stylesheets/_projects.scss b/app/assets/stylesheets/_projects.scss index a1176d17..bf6afc9d 100644 --- a/app/assets/stylesheets/_projects.scss +++ b/app/assets/stylesheets/_projects.scss @@ -1,3 +1,4 @@ @import 'projects-index'; @import 'projects-new'; @import 'projects-edit'; +@import 'projects-analyses'; diff --git a/app/controllers/project_analyses_controller.rb b/app/controllers/project_analyses_controller.rb new file mode 100644 index 00000000..2ada5b14 --- /dev/null +++ b/app/controllers/project_analyses_controller.rb @@ -0,0 +1,27 @@ +class ProjectAnalysesController < ApplicationController + before_action :require_login + before_action :set_project + + def show_or_create + # Delete existing analysis if force is true + if params[:force].to_s == "true" && @project.project_analysis.present? + @project.project_analysis.destroy + end + + # Use cached analysis if it exists, otherwise generate a new one + @project_analysis = @project.project_analysis || ProjectAnalysisGenerator.new(@project.id).call + + flash[:notice] = if @project_analysis.persisted? + I18n.t("project_analyses.flash.cache_loaded") + else + I18n.t("project_analyses.flash.generated") + end + render :show + end + + private + + def set_project + @project = Project.find(params[:project_id]) + end +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 00000000..d394c3d1 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/project_analysis_job.rb b/app/jobs/project_analysis_job.rb new file mode 100644 index 00000000..3e7b7874 --- /dev/null +++ b/app/jobs/project_analysis_job.rb @@ -0,0 +1,9 @@ +class ProjectAnalysisJob < ApplicationJob + queue_as :default + + def perform(project_id) + ProjectAnalysisGenerator.new(project_id).call + rescue => e + Rails.logger.error("Failed to generate project analysis for Project #{project_id}: #{e.message}") + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 1704e14d..c08d4308 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -15,6 +15,7 @@ class Project < ApplicationRecord has_many :real_photos, -> { merge(Photo.image_files.sorted) }, class_name: "Photo" has_one :primary_photo, -> { merge(Photo.image_files.sorted) }, class_name: "Photo" has_one :project_moderation, dependent: :destroy + has_one :project_analysis, dependent: :destroy before_validation UrlNormalizer.new(:url, :rss_feed_url) diff --git a/app/models/project_analysis.rb b/app/models/project_analysis.rb new file mode 100644 index 00000000..ed135073 --- /dev/null +++ b/app/models/project_analysis.rb @@ -0,0 +1,29 @@ +class ProjectAnalysis < ApplicationRecord + belongs_to :project, optional: true + + validates :summary, :language_code, :applicant_role, :scores, presence: true + + validate :validate_scores + + # Serialize the tags array + # serialize :tags, Array + + # Access scores as a nested object + def scores + @scores ||= ProjectAnalysisScores.new(self) + end + + private def validate_scores + scores_class = ProjectAnalysisScores::SCORE_FIELDS + scores_class.each do |field| + score_value = send(:"#{field}_score") + reason = send(:"#{field}_score_reason") + + if score_value.nil? || reason.blank? + errors.add(:base, "Both score and reason must be present for #{field}") + elsif score_value < 0 || score_value > 1 + errors.add(:base, "Score for #{field} must be between 0.0 and 1.0") + end + end + end +end diff --git a/app/models/project_analysis_score.rb b/app/models/project_analysis_score.rb new file mode 100644 index 00000000..46df58c1 --- /dev/null +++ b/app/models/project_analysis_score.rb @@ -0,0 +1,9 @@ +# app/models/project_analysis_score.rb +class ProjectAnalysisScore + attr_accessor :score, :reason + + def initialize(score:, reason:) + @score = score + @reason = reason + end +end diff --git a/app/models/project_analysis_scores.rb b/app/models/project_analysis_scores.rb new file mode 100644 index 00000000..bf50b871 --- /dev/null +++ b/app/models/project_analysis_scores.rb @@ -0,0 +1,35 @@ +class ProjectAnalysisScores + SCORE_FIELDS = %w[ + self_interest + proximity + novelty + budget_clarity + whimsy + social_good + reach + feasibility + urgency + impact_of_grant + credibility + innovation + sustainability + community_involvement + cultural_relevance + scalability + alignment_with_mission + artistic_merit + ] + + def initialize(project_analysis) + @project_analysis = project_analysis + end + + SCORE_FIELDS.each do |field| + define_method(field) do + ProjectAnalysisScore.new( + score: @project_analysis.send(:"#{field}_score"), + reason: @project_analysis.send(:"#{field}_score_reason") + ) + end + end +end diff --git a/app/services/project_analysis_generator.rb b/app/services/project_analysis_generator.rb new file mode 100644 index 00000000..29f2332a --- /dev/null +++ b/app/services/project_analysis_generator.rb @@ -0,0 +1,134 @@ +require "openai" +require "nokogiri" + +class ProjectAnalysisGenerator + def initialize(project_id) + @project = Project.find(project_id) + openai_config = Rails.application.config_for(:openai) + + # puts(JSON.generate({openai_config: openai_config}, indent: " ")) + + @openai_api_key = openai_config[:api_key] + raise "Error: OPENAI_API_KEY is not set in the configuration." if @openai_api_key.blank? + + @client = OpenAI::Client.new( + access_token: @openai_api_key, + log_error: true + ) + + config = openai_config[:project_analysis] + @model = config[:model] + @prompt = config[:prompt_text] + @format_instructions = config[:format_instructions] + @response_schema = config[:response_schema] + end + + def call + project_data = prepare_project_data + response = make_api_call(project_data) + + if response.nil? + raise "Error: OpenAI API call failed." + end + + parse_and_save_response(response) + end + + private + + def prepare_project_data + { + "project_id" => @project.id, + "title" => @project.title, + "about_me" => @project.about_me, + "about_project" => @project.about_project, + "use_for_money" => @project.use_for_money, + "created_at" => @project.created_at, + "chapter_name" => @project.chapter.name, + "chapter_country" => @project.chapter.country, + "chapter_description" => Nokogiri::HTML.fragment(@project.chapter.description).text.strip, + "extra_questions" => [ + {question: @project.extra_question_1, answer: @project.extra_answer_1}, + {question: @project.extra_question_2, answer: @project.extra_answer_2}, + {question: @project.extra_question_3, answer: @project.extra_answer_3} + ].reject { |qa| qa[:question].blank? && qa[:answer].blank? } + } + end + + def make_api_call(project_data) + parameters = { + model: @model, + messages: [ + {role: "user", content: @prompt}, + {role: "user", content: project_data.to_json}, + {role: "user", content: @format_instructions} + ], + functions: [ + { + name: "structured_output", + parameters: @response_schema + } + ], + function_call: "auto" + } + response = @client.chat(parameters: parameters) + # Avoid noisy output in tests/production. Use logger if needed. + # puts(JSON.generate({response: response}, indent: " ")) + # response.merge( + # "prompt_usage_data" => { "total_tokens" => 800, "prompt_tokens" => 500, "completion_tokens" => 300 }, + # "prompt_estimated_cost" => 0.02 + # ) + response + rescue => e + Rails.logger.error("OpenAI API call failed: #{e}") + nil + end + + def parse_and_save_response(response) + response_messages = response.dig("choices", 0, "message") + response_data = if response_messages["function_call"] + JSON.parse(response_messages["function_call"]["arguments"]) + else + JSON.parse(response_messages["content"]) + end + + project_analysis_attributes = map_response_to_attributes(response_data) + + project_analysis = ProjectAnalysis.find_or_initialize_by(project_id: @project.id) + project_analysis.assign_attributes(project_analysis_attributes) + project_analysis.save! + + project_analysis + rescue JSON::ParserError => e + Rails.logger.error("Failed to parse OpenAI response: #{e.message}") + nil + end + + def map_response_to_attributes(response_data) + project_analysis_attributes = { + summary: response_data["summary"], + language_code: response_data["language_code"], + applicant_role: response_data["applicant_role"], + funding_deadline: response_data["funding_deadline"], + tags: response_data["tags"], + suggestion: response_data["suggestions"] + # prompt_usage_data: response["usage"] + } + + # Parse scores + scores = response_data["scores"] + + ProjectAnalysisScores::SCORE_FIELDS.each do |field| + score_data = scores[field] + + if score_data + project_analysis_attributes["#{field}_score"] = score_data["score"] + project_analysis_attributes["#{field}_score_reason"] = score_data["reason"] + else + Rails.logger.warn("Score for #{field} is missing in the response.") + end + end + + project_analysis_attributes + end +end diff --git a/app/views/project_analyses/show.html.erb b/app/views/project_analyses/show.html.erb new file mode 100644 index 00000000..2ef8b374 --- /dev/null +++ b/app/views/project_analyses/show.html.erb @@ -0,0 +1,51 @@ +<% provide(:body_class, 'project-analysis-show') %> + +

<%= t(".title", title: @project.title) %>

+ + +

<%= t(".summary") %>: <%= @project_analysis.summary %>

+

<%= t(".applicant_role") %>: <%= @project_analysis.applicant_role.humanize %>

+

<%= t(".language") %>: <%= @project_analysis.language_code %>

+

<%= t(".tags") %>: <%= @project_analysis.tags.join(', ') %>

+ + + + + + + + + + + <% # Collect and sort the scores from highest to lowest %> + <% scores = ProjectAnalysisScores::SCORE_FIELDS.map do |field| + score_obj = @project_analysis.scores.send(field) + { + field: field.humanize, + score: score_obj.score.to_f * 100, + reason: score_obj.reason + } + end + # end.sort_by { |s| -s[:score] } %> + + <% # Loop through the sorted scores and apply color coding %> + <% scores.each do |score_hash| %> + <% # Determine the color classes based on the score %> + <% if score_hash[:score] >= 80 %> + <% row_style = 'background-color: #d1fae5; color: #022c22;' %> + <% elsif score_hash[:score] >= 50 %> + <% row_style = 'background-color: #fef9c3; color: #422006;' %> + <% else %> + <% row_style = 'background-color: #ffe4e6; color: #4c0519;' %> + <% end %> + + + + + + <% end %> + +
<%= t(".criteria") %><%= t(".score") %><%= t(".reason") %>
<%= score_hash[:field] %><%= (score_hash[:score]).round %><%= score_hash[:reason] %>
+ +

<%= t(".suggestions") %>: <%= @project_analysis.suggestion %>

+ diff --git a/app/views/projects/_project_details.html.erb b/app/views/projects/_project_details.html.erb index 3e226dd8..404b3e8a 100644 --- a/app/views/projects/_project_details.html.erb +++ b/app/views/projects/_project_details.html.erb @@ -13,9 +13,10 @@ <%= link_to I18n.t(".awesome", scope: "projects.project", count: voting_chapters.size, chapter: voting_chapter.slug), project_vote_path(project, chapter_id: voting_chapter.id), :method => :post, :remote => true, :class => "short-list", data: { chapter: voting_chapter.id } %> <% end %> - <% if display_project_actions?(current_user, project) %> - <%= link_to ''.html_safe, "#", class: "project-actions-toggle", data: { action: "click->toggler#toggle" } %> - <% end %> + <% if display_project_actions?(current_user, project) %> + <%= link_to ''.html_safe, "#", class: "project-actions-toggle", data: { action: "click->toggler#toggle" } %> + <%= link_to I18n.t("auto_awesome", scope: "projects.project.buttons"), show_or_create_project_project_analysis_path(project), class: "auto-awesome" %> + <% end %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index ef055930..9647e7f1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -34,6 +34,20 @@ en: error: There was a problem saving your project flashes: failure_when_not_signed_in: You must be logged in. + project_analyses: + flash: + cache_loaded: Project analysis loaded from cache. + generated: New project analysis generated. + show: + title: Project Analysis for "%{title}" + summary: Summary + applicant_role: Applicant Role + language: Language + tags: Tags + criteria: Criterion + score: Score + reason: Reason + suggestions: Suggestions layouts: application: main-header: The Awesome Foundation @@ -183,6 +197,8 @@ en: add-comment: Add a comment confirm-delete-comment: Are you sure you want to remove this comment? delete-comment: Delete comment + buttons: + auto_awesome: Auto-awesome hide_form: hide: Hide unhide: Unhide diff --git a/config/openai.yml b/config/openai.yml new file mode 100644 index 00000000..717d1053 --- /dev/null +++ b/config/openai.yml @@ -0,0 +1,264 @@ +default_settings: &default + api_key: <%= ENV['OPENAI_API_KEY'] || '' %> + project_analysis: + model: gpt-4o + prompt_text: | + You are an Awesome Foundation trustee. You have been asked to review the + following grant application and provide your feedback to qualify it for the + monthly award. + + format_instructions: | + DO NOT start the summary with unnecessary setup like 'This grant application + seeks funding for a community garden in Rogers Park...'. Instead just say + something like 'A community garden in Rogers Park...' + + For the score fields, provide a decimal value between 0 and 1, as well as a short + 2-sentence explanation for why the application meets or does not meet the + criteria described in the field. + + For the score fields, provide a JSON object with the score value as a number + (without quotes) and the reason field as a string. + + DO provide values for all scores. + DO NOT provide any other text in the result beyond the JSON. + DO NOT surround the JSON in backticks. + + Return all responses in English. + + response_schema: + "$defs": + ApplicantRole: + enum: + - community_leader + - teacher + - student + - artist + - academic + - entrepreneur + - nonprofit_organizer + - activist + - healthcare_worker + - scientist + - engineer + - social_worker + - musician + - filmmaker + - writer + - performer + - environmentalist + - technologist + - craftsperson + - parent + title: ApplicantRole + type: string + ProjectTag: + enum: + - science + - music + - technology + - art + - education + - environment + - health + - community + - social_justice + - culture + - history + - literature + - sports + - mental_health + - youth + - seniors + - accessibility + - urban_gardening + - sustainability + - renewable_energy + - robotics + - coding + - wildlife_conservation + - food_security + - housing + - dance + - theater + - filmmaking + - photography + - public_art + - disaster_relief + - transportation + - civic_engagement + - public_health + - water_safety + - astronomy + - journalism + - advocacy + - peacebuilding + - maker_space + - entrepreneurship + - clean_technology + - urban_planning + - heritage + - migration + - storytelling + - digital_inclusion + - biodiversity + - maker_education + - language_preservation + - homelessness + - libraries + - autism + - arts_activism + - food + - disability + - gender_equality + - lgbtq + - bicycles + - media_literacy + - animals + - crafts + - parks + title: ProjectTag + type: string + Score: + properties: + score: + title: Score + type: number + description: + Decimal value between 0 and 1 representing whether or not the + project fits this criterion described in the field. Two digit precision. + reason: + title: Reason + type: string + description: + One to two sentences explaining why the application received its score + for this field. + required: + - score + - reason + title: Score + type: object + Scores: + properties: + reach: + "$ref": "#/$defs/Score" + description: | + Number of people impacted, focusing on those local to the chapter's region. + alignment: + "$ref": "#/$defs/Score" + description: | + Project alignment with the Awesome Foundation’s mission and geographical focus. + novelty: + "$ref": "#/$defs/Score" + description: | + Originality, cleverness, and uniqueness of the project’s concept or approach. Should + not be an obvious idea that replicates other existing work. + use_of_proceeds: + "$ref": "#/$defs/Score" + description: | + Clarity and detail of fund allocation within the proposal, as well as ensuring the + funds will NOT finacially enrich the applicant directly (e.g., stipends paid to the + applicant). + impact_of_grant: + "$ref": "#/$defs/Score" + description: | + Significance of the $1000 grant in achieving the project’s goals. The grant should + be executable solely with no additional outside funding. + feasibility: + "$ref": "#/$defs/Score" + description: | + Practicality of executing the project within the stated timeline and budget. Also + evaluate applicant’s experience and credibility relevant to the project’s scope. + sustainability: + "$ref": "#/$defs/Score" + description: | + Long-term viability and potential for impact beyond the grant period, + as well as potential for project expansion or replication in other contexts. + urgency: + "$ref": "#/$defs/Score" + description: | + Immediacy of need for funds; score lower if impact isn’t time-sensitive. + whimsy: + "$ref": "#/$defs/Score" + description: | + Playful, imaginative qualities that inspire curiosity or delight. + social_good: + "$ref": "#/$defs/Score" + description: | + Potential for positive social impact and contribution to community welfare. + required: + - reach + - alignment + - novelty + - use_of_proceeds + - impact_of_grant + - feasibility + - sustainability + - urgency + - whimsy + - social_good + title: Scores + type: object + properties: + project_id: + title: Project Id + type: integer + summary: + description: + A brief overview of the project, outlining its goals, activities, + and potential risks. + title: Summary + type: string + tags: + description: + List of tags representing the project's themes. Should be instances + of the provided list of enums. + items: + "$ref": "#/$defs/ProjectTag" + title: Tags + type: array + applicant_role: + "$ref": "#/$defs/ApplicantRole" + description: + The applicant's primary role or background in relation to the project. + Should be an be one of the provided list of enums. + language_code: + description: + The ISO 639-1 code representing the language of the project description, + e.g., 'en' for English. + title: Language Code + type: string + funding_deadline: + anyOf: + - type: string + - type: "null" + default: + description: + The date by which the grant funds are needed to ensure timely execution + of the project, in ISO format. + title: Funding Deadline + suggestions: + description: + 4 - 6 sentences with any additional comments or suggestions for the + applicant. + title: Suggestions + type: string + scores: + "$ref": "#/$defs/Scores" + required: + - project_id + - summary + - tags + - applicant_role + - language_code + - scores + title: ProjectSummary + type: object + +development: + <<: *default + +production: + <<: *default + +test: + <<: *default diff --git a/config/routes.rb b/config/routes.rb index 7ee6d559..7ee77e9a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,6 +66,9 @@ put :confirm_legit put :undo end + resource :project_analysis, :only => [] do + get "show_or_create", :to => "project_analyses#show_or_create" + end end resources :moderations, only: [:index] diff --git a/db/migrate/20241028031112_create_project_analyses.rb b/db/migrate/20241028031112_create_project_analyses.rb new file mode 100644 index 00000000..bf2240de --- /dev/null +++ b/db/migrate/20241028031112_create_project_analyses.rb @@ -0,0 +1,41 @@ +class CreateProjectAnalyses < ActiveRecord::Migration[6.1] + def change + create_table :project_analyses do |t| + t.references :project, foreign_key: true, null: false + + t.text :summary + t.string :language_code + t.string :applicant_role + t.date :funding_deadline + t.string :tags, array: true + + # Scores (flattened) + score_fields = [ + "reach", + "proximity", + "self_interest", + "budget_clarity", + "impact_of_grant", + "feasibility", + "credibility", + "innovation", + "sustainability", + "community_involvement", + "cultural_relevance", + "scalability", + "urgency", + "alignment_with_mission", + "whimsy", + "social_good", + "artistic_merit" + ] + + score_fields.each do |field| + t.decimal "#{field}_score", precision: 4, scale: 2 + t.text "#{field}_score_reason" + end + + t.timestamps + end + end +end diff --git a/db/migrate/20241029213306_add_project_analysis_suggestion_column.rb b/db/migrate/20241029213306_add_project_analysis_suggestion_column.rb new file mode 100644 index 00000000..46f3af42 --- /dev/null +++ b/db/migrate/20241029213306_add_project_analysis_suggestion_column.rb @@ -0,0 +1,5 @@ +class AddProjectAnalysisSuggestionColumn < ActiveRecord::Migration[6.1] + def change + add_column :project_analyses, :suggestion, :text + end +end diff --git a/db/migrate/20241029213419_add_project_analysis_prompt_info_columns.rb b/db/migrate/20241029213419_add_project_analysis_prompt_info_columns.rb new file mode 100644 index 00000000..b1f11613 --- /dev/null +++ b/db/migrate/20241029213419_add_project_analysis_prompt_info_columns.rb @@ -0,0 +1,11 @@ +class AddProjectAnalysisPromptInfoColumns < ActiveRecord::Migration[6.1] + def change + add_column :project_analyses, :prompt_usage_data, :jsonb, default: {}, null: false + add_column :project_analyses, :prompt_tokens, :integer + add_column :project_analyses, :completion_tokens, :integer + add_column :project_analyses, :cached_tokens, :integer + add_column :project_analyses, :prompt_estimated_cost, :float + add_column :project_analyses, :prompt_input_params, :jsonb, default: {}, null: false + add_column :project_analyses, :prompt_response_data, :jsonb, default: {}, null: false + end +end diff --git a/db/migrate/20241030001603_add_project_analysis_novelty_score.rb b/db/migrate/20241030001603_add_project_analysis_novelty_score.rb new file mode 100644 index 00000000..b17d98f0 --- /dev/null +++ b/db/migrate/20241030001603_add_project_analysis_novelty_score.rb @@ -0,0 +1,6 @@ +class AddProjectAnalysisNoveltyScore < ActiveRecord::Migration[6.1] + def change + add_column :project_analyses, :novelty_score, :decimal, precision: 4, scale: 2 + add_column :project_analyses, :novelty_score_reason, :text + end +end diff --git a/docker-compose.yml b/docker-compose.yml index 2a851a4f..9de3099d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,8 @@ services: image: postgres volumes: - postgresqldata:/var/lib/postgresql/data + ports: + - "6432:5432" environment: POSTGRES_USER: "postgres" POSTGRES_PASSWORD: "mysecretpassword" diff --git a/spec/jobs/project_analysis_job_spec.rb b/spec/jobs/project_analysis_job_spec.rb new file mode 100644 index 00000000..5e6d9ce9 --- /dev/null +++ b/spec/jobs/project_analysis_job_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +RSpec.describe ProjectAnalysisJob, type: :job do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/services/project_analysis_generator_integration_spec.rb b/spec/services/project_analysis_generator_integration_spec.rb new file mode 100644 index 00000000..fad2620d --- /dev/null +++ b/spec/services/project_analysis_generator_integration_spec.rb @@ -0,0 +1,25 @@ +require "spec_helper" + +RSpec.describe "ProjectAnalysisGenerator Integration", type: :integration do + let(:project) { create(:project) } + + before do + skip "Integration test skipped: OPENAI_API_KEY is not configured." if ENV["OPENAI_API_KEY"].blank? + end + + it "calls the OpenAI API with a real key and validates the response", integration: true do + generator = ProjectAnalysisGenerator.new(project.id) + expect { generator.call }.not_to raise_error + + analysis = ProjectAnalysis.last + expect(analysis).not_to be_nil + expect(analysis.summary).to be_a(String) + expect(analysis.language_code).to be_a(String) + expect(analysis.scores).to be_a(Hash) + expect(analysis.scores).to include(*ProjectAnalysisScores::SCORE_FIELDS) + + [*ProjectAnalysisScores::SCORE_FIELDS].each do |field| + expect(analysis.scores[field][:reason]).to be_a(String) + end + end +end diff --git a/spec/services/project_analysis_generator_spec.rb b/spec/services/project_analysis_generator_spec.rb new file mode 100644 index 00000000..191eda3e --- /dev/null +++ b/spec/services/project_analysis_generator_spec.rb @@ -0,0 +1,70 @@ +require "spec_helper" + +RSpec.describe ProjectAnalysisGenerator, type: :service do + let(:project) { create(:project) } + let(:openai_config) do + { + api_key: "test-api-key", + project_analysis: { + model: "gpt-test", + prompt_text: "Prompt", + format_instructions: "Format", + response_schema: {} + } + } + end + + let(:mock_client) { mock("OpenAI::Client") } + + before do + # Mock the OpenAI API client (Mocha) + OpenAI::Client.stubs(:new).returns(mock_client) + Rails.application.stubs(:config_for).with(:openai).returns(openai_config) + end + + describe "#call" do + context "when OpenAI API returns a valid response" do + it "creates a ProjectAnalysis record" do + scores = ProjectAnalysisScores::SCORE_FIELDS.each_with_object({}) do |field, hash| + hash[field] = {"score" => 0.8, "reason" => "#{field} ok"} + end + # Mock the API response + api_response = { + "choices" => [ + { + "message" => { + "content" => { + "summary" => "A great project.", + "language_code" => "en", + "applicant_role" => "Founder", + "scores" => scores + }.to_json + } + } + ] + } + mock_client.stubs(:chat).returns(api_response) + + generator = ProjectAnalysisGenerator.new(project.id) + expect { + generator.call + }.to change { ProjectAnalysis.count }.by(1) + + analysis = ProjectAnalysis.last + expect(analysis.summary).to eq("A great project.") + expect(analysis.language_code).to eq("en") + expect(analysis.scores.feasibility.score).to eq(0.8) + expect(analysis.scores.feasibility.reason).to eq("feasibility ok") + end + end + + context "when OpenAI API fails" do + it "raises an error" do + mock_client.stubs(:chat).raises(StandardError.new("API Error")) + + generator = ProjectAnalysisGenerator.new(project.id) + expect { generator.call }.to raise_error("Error: OpenAI API call failed.") + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 423b08c3..88dcec9c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -19,6 +19,7 @@ config.infer_spec_type_from_file_location! config.mock_with :mocha + config.include FactoryBot::Syntax::Methods config.before(:each){ DatabaseCleaner.start } config.append_after(:each){ DatabaseCleaner.clean } config.before(:each){ FactoryBot.create(:chapter, :name => "Any") }