Skip to content

Commit 7be4825

Browse files
committed
sighh....
1 parent ae71b48 commit 7be4825

File tree

5 files changed

+354
-0
lines changed

5 files changed

+354
-0
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
class ShowcaseController < ApplicationController
2+
before_action :authenticate_user!
3+
4+
def new
5+
@project = Showcase::Matchmaker.new(user: current_user).pick
6+
7+
if @project.nil?
8+
redirect_to explore_path, alert: "No eligible projects available right now." and return
9+
end
10+
11+
ActiveRecord::Associations::Preloader
12+
.new(records: [ @project ], associations: [ { banner_attachment: :blob } ])
13+
.call
14+
15+
@devlogs = Devlog.where(project_id: @project.id)
16+
.includes({ user: :user_badges }, { file_attachment: :blob })
17+
.order(:created_at)
18+
19+
@grade_labels = VoteMf::GRADE_LABELS
20+
@criteria = VoteMf::CRITERIA
21+
@vote_mf = VoteMf.new(project: @project, voter: current_user)
22+
23+
session[:showcase_started_at] = Time.current.to_i
24+
end
25+
26+
def create
27+
# yes, this is indeed vibecoded. I do not have motivation to work on SoM's codebase. check out battlemage tho. that said, i wrote the matchmakign service and the models by hand
28+
@project = Project.find(params.require(:project_id))
29+
if @project.user_id == current_user.id
30+
redirect_to showcase_path, alert: "You cannot vote on your own project." and return
31+
end
32+
33+
raw_ballot = params.require(:vote_mf).permit(ballot: {})[:ballot] || {}
34+
35+
time_spt_ms = nil
36+
if session[:showcase_started_at].present?
37+
begin
38+
started_at = Time.at(session[:showcase_started_at].to_i)
39+
time_spt_ms = ((Time.current - started_at) * 1000).to_i.clamp(0, 86_400_000)
40+
rescue StandardError
41+
time_spt_ms = nil
42+
end
43+
end
44+
45+
@vote_mf = VoteMf.new(
46+
project: @project,
47+
voter: current_user,
48+
ballot: raw_ballot,
49+
time_spent_voting_ms: time_spt_ms
50+
)
51+
52+
if @vote_mf.save
53+
session.delete(:showcase_started_at)
54+
redirect_to showcase_path, notice: "Thanks! Your vote was recorded."
55+
else
56+
ActiveRecord::Associations::Preloader
57+
.new(records: [ @project ], associations: [ { banner_attachment: :blob } ])
58+
.call
59+
@devlogs = Devlog.where(project_id: @project.id)
60+
.includes({ user: :user_badges }, { file_attachment: :blob })
61+
.order(:created_at)
62+
@grade_labels = VoteMf::GRADE_LABELS
63+
@criteria = VoteMf::CRITERIA
64+
flash.now[:alert] = @vote_mf.errors.full_messages.join(", ")
65+
render :new, status: :unprocessable_entity
66+
end
67+
end
68+
end

app/models/vote_mf.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ class VoteMf < ApplicationRecord
4747
validates :voter_id, uniqueness: { scope: :project_id }
4848
validate :validate_ballot
4949

50+
# it will return a 0.0..1.0 (b/w 0 and 1) normalized composite score across all criteria – sum / n_critieria * (label - 1)
51+
def normalized_total_score
52+
values = ballot.is_a?(Hash) ? ballot.values : []
53+
return nil if values.empty?
54+
55+
int_values = values.map(&:to_i)
56+
max_total = (CRITERIA.size * (GRADE_LABELS.size - 1)).to_f
57+
return 0.0 if max_total <= 0
58+
59+
(int_values.sum / max_total).clamp(0.0, 1.0)
60+
end
61+
5062
def ballot_labels
5163
return {} unless ballot.is_a?(Hash)
5264
ballot.to_h.transform_values do |v|
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
module Showcase
2+
class Matchmaker
3+
# my SO is a great matchmaker. hit me up on slack if you want lore!
4+
5+
MAX_COMPOSITE_STDDEV = 0.5
6+
TARGET_VOTERS = 15
7+
WEIGHT_EXPOSURE = 0.6
8+
WEIGHT_UNCERTAINTY = 0.4
9+
SOFTMAX_TEMPERATURE = 1.0
10+
11+
def initialize(user: )
12+
@user = user
13+
end
14+
15+
def pick
16+
pool_project_ids = build_pool_project_ids
17+
return nil if pool_project_ids.empty?
18+
19+
user_project = Project.where(id: pool_project_ids, user_id: @user.id).pluck(:id)
20+
already_voted = VoteMf.where(voter_id: @user.id, project_id: pool_project_ids).pluck(:project_id)
21+
22+
excluded_ids = (user_project + already_voted).uniq
23+
candidate = pool_project_ids - excluded_ids
24+
return nil if candidate.empty?
25+
26+
exposure_by_id = exposure_scores(candidate)
27+
uncertainty_by_id = uncertainty_scores(candidate)
28+
29+
scores = candidate.index_with do |pid|
30+
(WEIGHT_EXPOSURE * (exposure_by_id[pid] || 0.0)) + (WEIGHT_UNCERTAINTY * (uncertainty_by_id[pid] || 0.0))
31+
end
32+
33+
chosen_id = softmax(scores)
34+
Project.find_by(id: chosen_id)
35+
end
36+
37+
private
38+
39+
def build_pool_project_ids
40+
p = [2160,
41+
1779,
42+
9722,
43+
11764,
44+
792,
45+
2701,
46+
11177,
47+
11979,
48+
1469,
49+
12938,
50+
991,
51+
3423,
52+
1598,
53+
6392,
54+
12342,
55+
13073,
56+
10944,
57+
13782,
58+
169,
59+
6838,
60+
1775,
61+
914,
62+
1014,
63+
2521,
64+
133,
65+
8597,
66+
12542,
67+
1727,
68+
9530,
69+
947,
70+
10359,
71+
837,
72+
1932,
73+
12484,
74+
4041,
75+
53,
76+
398,
77+
7260,
78+
10992,
79+
4192,
80+
2179,
81+
2290,
82+
9969,
83+
12762,
84+
2365,
85+
5812,
86+
2753,
87+
13844,
88+
8326,
89+
6472,
90+
12114,
91+
3529,
92+
29,
93+
13227,
94+
1730,
95+
11304,
96+
1537,
97+
8244,
98+
161,
99+
196,
100+
4283,
101+
10458,
102+
4637,
103+
1131,
104+
13321,
105+
2787,
106+
4032,
107+
723,
108+
11162,
109+
7236,
110+
311,
111+
2470,
112+
7613,
113+
12158,
114+
13678,
115+
4907,
116+
2181,
117+
9065,
118+
4310,
119+
10026,
120+
2429,
121+
13952,
122+
3152,
123+
12344,
124+
3888,
125+
5038,
126+
664,
127+
3761,
128+
15,
129+
11442,
130+
6059,
131+
8920,
132+
5328,
133+
6737,
134+
12728,
135+
8994,
136+
791,
137+
5187,
138+
10803,
139+
5942]
140+
p & Project.where(id: p).pluck(:id)
141+
end
142+
143+
def exposure_scores(project_ids)
144+
return {} if project_ids.empty?
145+
146+
voters_count = VoteMf.where(project_id: project_ids).group(:project_id).distinct.count(:voter_id)
147+
project_ids.index_with do |p|
148+
v = voters_count[p].to_i
149+
((TARGET_VOTERS - v) / TARGET_VOTERS.to_f).clamp(0.0, 1.0)
150+
end
151+
end
152+
153+
def uncertainty_scores(project_ids)
154+
return {} if project_ids.empty?
155+
156+
scores_by_project_id = Hash.new { |h, k| h[k] = [] }
157+
VoteMf.where(project_id: project_ids).select(:project_id, :ballot).find_each(batch_size: 50) do |vmf|
158+
s = vmf.normalized_total_score
159+
scores_by_project_id[vmf.project_id] << s if s
160+
end
161+
162+
project_ids.index_with do |pid|
163+
scores = scores_by_project_id[pid]
164+
next 1.0 if scores.empty?
165+
166+
mean = scores.sum.to_f / scores.size
167+
var = scores.sum { |x| (x - mean) ** 2 } / scores.size.to_f
168+
std = Math.sqrt(var)
169+
(std / MAX_COMPOSITE_STDDEV).clamp(0.0, 1.0)
170+
end
171+
end
172+
173+
def softmax(scores_by_id)
174+
return nil if scores_by_id.empty?
175+
176+
ids = scores_by_id.keys
177+
values = ids.map { |id| scores_by_id[id].to_f }
178+
179+
max_v = values.max
180+
temperature = [ SOFTMAX_TEMPERATURE.to_f, 1e-6 ].max
181+
exps = values.map { |v| Math.exp((v - max_v) / temperature) }
182+
sum = exps.sum
183+
return ids[values.index(max_v)] if sum <= 0.0
184+
185+
probs = exps.map { |x| x / sum }
186+
r = rand
187+
cumulative = 0.0
188+
ids.each_with_index do |id, idx|
189+
cumulative += probs[idx]
190+
return id if r <= cumulative
191+
end
192+
ids.last
193+
end
194+
end
195+
end

app/views/showcase/new.html.erb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<div class="container mx-auto px-0 sm:px-4 py-4 md:py-8">
2+
<%= render "shared/page_title", title: "Showcase", subtitle: "Rate this project using Majority Judgment" %>
3+
4+
<div class="bg-[#F6DBBA] sm:rounded-xl p-4 mx-2 sm:mx-auto max-w-240">
5+
<div class="flex items-start gap-4">
6+
<div class="h-20 w-20 flex-shrink-0">
7+
<%= image_tag @project.banner, alt: @project.title, class: "w-full h-full object-cover rounded-lg", loading: "lazy" %>
8+
</div>
9+
<div class="flex-grow min-w-0">
10+
<h3 class="text-xl sm:text-3xl mb-1 truncate"><%= @project.title %></h3>
11+
<div class="text-base sm:text-lg text-gray-600">
12+
<div><%= @project.description %></div>
13+
</div>
14+
<div class="grid grid-cols-1 sm:flex sm:flex-wrap justify-center gap-3 sm:gap-4 mt-3">
15+
<% if @project.demo_link.present? %>
16+
<%= render C::StyledButton.new(
17+
text: "Demo",
18+
link: @project.demo_link,
19+
link_target: "_blank",
20+
icon: "world.svg",
21+
onclick: "event.stopPropagation()") %>
22+
<% end %>
23+
<% if @project.repo_link.present? %>
24+
<%= render C::StyledButton.new(
25+
text: "Repository",
26+
link: @project.repo_link,
27+
link_target: "_blank",
28+
icon: "git.svg",
29+
onclick: "event.stopPropagation()") %>
30+
<% end %>
31+
<%= render "shared/report", suspect: @project, already_reported: FraudReport.already_reported_by?(current_user, @project) %>
32+
</div>
33+
</div>
34+
</div>
35+
36+
<div class="space-y-4 sm:space-y-6 mt-8">
37+
<% @devlogs.each do |devlog| %>
38+
<%= render "devlogs/devlog_card",
39+
devlog: devlog,
40+
context: 'voting',
41+
show_comments_inline: false,
42+
show_comment_modal: false,
43+
show_likes: false,
44+
content_margin: 'p-2',
45+
no_parchment: true %>
46+
<% end %>
47+
</div>
48+
</div>
49+
50+
<div class="mx-2 sm:mx-auto mt-8 sm:mt-12 max-w-220">
51+
<%= render C::Container.new do %>
52+
<div class="p-0 sm:p-2">
53+
<%= form_with url: showcase_path, method: :post, data: { turbo: true }, class: "space-y-4" do |f| %>
54+
<%= hidden_field_tag :project_id, @project.id %>
55+
<%= hidden_field_tag :time_spent_voting_ms, 0, id: "time_spent_voting_ms" %>
56+
57+
<div class="space-y-3">
58+
<% @criteria.each do |criterion| %>
59+
<div class="bg-soft-bone/50 border border-saddle-taupe/30 rounded-lg p-3">
60+
<div class="mb-2 font-medium text-som-dark capitalize"><%= criterion %></div>
61+
<%= select_tag "vote_mf[ballot][#{criterion}]",
62+
options_for_select(@grade_labels),
63+
include_blank: "Select grade",
64+
required: true,
65+
class: "som-horizontal-input w-full" %>
66+
</div>
67+
<% end %>
68+
</div>
69+
70+
<div class="flex justify-center items-center w-full mt-4">
71+
<%= render C::StyledButton.new(text: "Submit", type: :submit) %>
72+
</div>
73+
<% end %>
74+
</div>
75+
<% end %>
76+
</div>
77+
</div>

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ def self.matches?(request)
205205
end
206206

207207
Rails.application.routes.draw do
208+
get "/showcase", to: "showcase#new", as: :showcase
209+
post "/showcase", to: "showcase#create"
208210
mount ActiveInsights::Engine => "/insights"
209211
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
210212

0 commit comments

Comments
 (0)