From d5c08247238f3de354eaedc243cc036fa5c64875 Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Wed, 4 Dec 2024 16:55:48 +0100
Subject: [PATCH 01/38] EN-7825 add variables to user_message_broadcasts,
welcome chat_message
---
app/helpers/users_helper.rb | 4 -
app/models/chat_message.rb | 18 ++++
app/models/concerns/availabilable.rb | 9 ++
app/models/conversation_message_broadcast.rb | 7 +-
.../onboarding/chat_messages_service.rb | 5 +-
.../admin/moderation_areas/_form.html.erb | 8 +-
.../user_message_broadcasts/_form.html.erb | 2 +-
.../admin/users/_onboarding_fields.html.erb | 2 +-
.../_interpolation_specifications.html.erb | 13 +++
spec/models/chat_message_spec.rb | 99 +++++++++++++++++++
10 files changed, 147 insertions(+), 20 deletions(-)
create mode 100644 app/views/common/_interpolation_specifications.html.erb
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 6f79f4f92..b02ef407d 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -1,8 +1,4 @@
module UsersHelper
- def day_name(day_number)
- I18n.t("date.day_names")[day_number.to_i % 7]
- end
-
def users_for_select users
users.map do |user|
["#{user.full_name} (#{user.email})", user.id]
diff --git a/app/models/chat_message.rb b/app/models/chat_message.rb
index cc3f9234d..468e8dbef 100644
--- a/app/models/chat_message.rb
+++ b/app/models/chat_message.rb
@@ -83,6 +83,24 @@ def path key
end
end
+ class << self
+ def interpolate message:, user:, author: nil
+ first_name = UserPresenter.format_first_name(user.first_name)
+
+ message
+ .gsub(/\{\{\s*first_name\s*\}\}/, first_name.to_s)
+ .gsub(/\{\{\s*email\s*\}\}/, user.email.to_s)
+ .gsub(/\{\{\s*phone\s*\}\}/, user.phone.to_s)
+ .gsub(/\{\{\s*city\s*\}\}/, user.city.to_s)
+ .gsub(/\{\{\s*uuid\s*\}\}/, user.uuid.to_s)
+ .gsub(/\{\{\s*default_neighborhood\s*\}\}/, user.default_neighborhood&.name.to_s)
+ .gsub(/\{\{\s*interests\s*\}\}/, user.interest_i18n.join(', '))
+ .gsub(/\{\{\s*involvements\s*\}\}/, user.involvement_i18n.join(', '))
+ .gsub(/\{\{\s*availability\s*\}\}/, user.availability_formatted.to_s)
+ .gsub(/\{\{\s*interlocutor\s*\}\}/, author&.first_name.to_s)
+ end
+ end
+
def active?
status.to_sym == :active
end
diff --git a/app/models/concerns/availabilable.rb b/app/models/concerns/availabilable.rb
index 436d0636c..b6142cf16 100644
--- a/app/models/concerns/availabilable.rb
+++ b/app/models/concerns/availabilable.rb
@@ -40,4 +40,13 @@ def validate_availability_format
end
end
+ def availability_formatted
+ availability.map do |day_number, hours|
+ "#{Availabilable.day_name(day_number)} : #{hours.join(', ')}"
+ end.join("\n")
+ end
+
+ def self.day_name day_number
+ I18n.t("date.day_names")[day_number.to_i % 7]
+ end
end
diff --git a/app/models/conversation_message_broadcast.rb b/app/models/conversation_message_broadcast.rb
index 46b6e7c85..eca88bdc1 100644
--- a/app/models/conversation_message_broadcast.rb
+++ b/app/models/conversation_message_broadcast.rb
@@ -17,12 +17,7 @@ def find_with_cast id
end
def content_for_user user
- content
- .gsub("{{first_name}}", user.first_name.to_s)
- .gsub("{{email}}", user.email.to_s)
- .gsub("{{phone}}", user.phone.to_s)
- .gsub("{{city}}", user.city.to_s)
- .gsub("{{uuid}}", user.uuid.to_s)
+ ChatMessage.interpolate(message: content, user: user)
end
def recipients
diff --git a/app/services/onboarding/chat_messages_service.rb b/app/services/onboarding/chat_messages_service.rb
index 5dc830e28..139b4a76f 100644
--- a/app/services/onboarding/chat_messages_service.rb
+++ b/app/services/onboarding/chat_messages_service.rb
@@ -38,11 +38,8 @@ def self.deliver_welcome_message
next if messages.empty?
- first_name = UserPresenter.format_first_name user.first_name
-
messages.each do |message|
- message = message.gsub(/\{\{\s*first_name\s*\}\}/, first_name)
- message = message.gsub(/\{\{\s*interlocutor\s*\}\}/, author.first_name) if author.present?
+ message = ChatMessage.interpolate(message: message, user: user, author: author)
builder = ChatServices::ChatMessageBuilder.new(
user: author,
diff --git a/app/views/admin/moderation_areas/_form.html.erb b/app/views/admin/moderation_areas/_form.html.erb
index acefe9d79..6ed2ef7da 100644
--- a/app/views/admin/moderation_areas/_form.html.erb
+++ b/app/views/admin/moderation_areas/_form.html.erb
@@ -65,7 +65,7 @@
pour le profil <%= t 'community.entourage.goals_compact.offer_help' %>
<% end %>
<%= f.text_area :welcome_message_1_offer_help, rows: 10, class: "form-control" %>
- Utilisez {{first_name}}
pour insérer le prénom du destinataire.
+ <%= render partial: 'common/interpolation_specifications' %>
@@ -76,7 +76,7 @@
si <%= t 'community.entourage.goals_compact.ask_for_help' %>
<% end %>
<%= f.text_area :welcome_message_1_ask_for_help, rows: 10, class: "form-control" %>
- Utilisez {{first_name}}
pour insérer le prénom du destinataire.
+ <%= render partial: 'common/interpolation_specifications' %>
@@ -87,7 +87,7 @@
pour le profil <%= t 'community.entourage.goals_compact.organization' %>
<% end %>
<%= f.text_area :welcome_message_1_organization, rows: 10, class: "form-control" %>
- Utilisez {{first_name}}
pour insérer le prénom du destinataire.
+ <%= render partial: 'common/interpolation_specifications' %>
@@ -98,7 +98,7 @@
pour le profil <%= t 'community.entourage.goals_compact.goal_not_known' %>
<% end %>
<%= f.text_area :welcome_message_1_goal_not_known, rows: 10, class: "form-control" %>
- Utilisez {{first_name}}
pour insérer le prénom du destinataire.
+ <%= render partial: 'common/interpolation_specifications' %>
diff --git a/app/views/admin/user_message_broadcasts/_form.html.erb b/app/views/admin/user_message_broadcasts/_form.html.erb
index 2b8c6cfc9..32884c863 100644
--- a/app/views/admin/user_message_broadcasts/_form.html.erb
+++ b/app/views/admin/user_message_broadcasts/_form.html.erb
@@ -137,8 +137,8 @@
<% if @user_message_broadcast.draft? %>
diff --git a/app/views/admin/users/_onboarding_fields.html.erb b/app/views/admin/users/_onboarding_fields.html.erb
index 6c7dbf682..ed93d21ea 100644
--- a/app/views/admin/users/_onboarding_fields.html.erb
+++ b/app/views/admin/users/_onboarding_fields.html.erb
@@ -5,7 +5,7 @@
<% @user.availability.each do |day, slots| %>
-
- <%= day_name(day) %>:
+ <%= Availabilable.day_name(day) %>:
<%= slots.join(', ') %>
<% end %>
diff --git a/app/views/common/_interpolation_specifications.html.erb b/app/views/common/_interpolation_specifications.html.erb
new file mode 100644
index 000000000..1c414a98e
--- /dev/null
+++ b/app/views/common/_interpolation_specifications.html.erb
@@ -0,0 +1,13 @@
+Les variables suivantes sont disponibles :
+
+ {{first_name}}
: prénom du destinataire
+ {{email}}
: email du destinataire
+ {{phone}}
téléphone du destinataire
+ {{city}}
: ville du destinataire
+ {{uuid}}
: uuid du destinataire
+ {{default_neighborhood}}
: nom du groupe de voisins par défaut du destinataire
+ {{interests}}
: centres d'intérêt du destinataire
+ {{involvements}}
: envies d'agir du destinataire
+ {{availability}}
: disponibilités du destinataire
+ {{interlocutor}}
: prénom du modérateur lié au destinataire
+
diff --git a/spec/models/chat_message_spec.rb b/spec/models/chat_message_spec.rb
index 8e37f5955..61ae66ecb 100644
--- a/spec/models/chat_message_spec.rb
+++ b/spec/models/chat_message_spec.rb
@@ -20,6 +20,105 @@
end
end
+ describe '.interpolate' do
+ let(:user) { create(:public_user,
+ first_name: 'John',
+ email: 'john.doe@example.com',
+ phone: '+33612345678',
+ uuid: '123e4567-e89b-12d3-a456-426614174000',
+ interest_list: 'jeux, cuisine',
+ involvement_list: 'outings, resources',
+ availability: {
+ "1" => ["09:00-12:00", "14:00-18:00"],
+ "2" => ["10:00-12:00"]
+ },
+ address: address,
+ addresses: [address]
+ )}
+
+ let(:other_user) { create(:public_user) }
+ let(:author) { create(:public_user, first_name: 'Alice') }
+
+ let(:neighborhood) { create(:neighborhood, name: "Groupe de Paris") }
+ let(:address) { create :address, city: "Paris" }
+
+ before do
+ allow(user).to receive(:default_neighborhood).and_return(neighborhood)
+ allow(UserPresenter).to receive(:format_first_name).with('John').and_return('John')
+ end
+
+ it 'replaces placeholders in the message' do
+ message = <<~TEXT
+ Hello {{ first_name }},
+ Your email is {{ email }}, and your phone number is {{ phone }}.
+ You live in {{ city }} and your ID is {{ uuid }}.
+ Default neighborhood: {{ default_neighborhood }}.
+ Your interests: {{ interests }}.
+ Your involvements: {{ involvements }}.
+ Availability: {{ availability }}.
+ Interlocutor: {{ interlocutor }}.
+ TEXT
+
+ expected_message = <<~TEXT
+ Hello John,
+ Your email is john.doe@example.com, and your phone number is +33612345678.
+ You live in Paris and your ID is 123e4567-e89b-12d3-a456-426614174000.
+ Default neighborhood: Groupe de Paris.
+ Your interests: Jeux, Cuisine.
+ Your involvements: Participer à des événements de convivialité, Apprendre avec des contenus pédagogiques.
+ Availability: lundi : 09:00-12:00, 14:00-18:00
+ mardi : 10:00-12:00.
+ Interlocutor: Alice.
+ TEXT
+
+ result = ChatMessage.interpolate(message: message, user: user, author: author)
+ expect(result.strip).to eq(expected_message.strip)
+ end
+
+ it 'replaces placeholders in the message when user has little information' do
+ message = <<~TEXT
+ Hello {{ first_name }},
+ Your email is {{ email }}, and your phone number is {{ phone }}.
+ You live in {{ city }} and your ID is {{ uuid }}.
+ Default neighborhood: {{ default_neighborhood }}.
+ Your interests: {{ interests }}.
+ Your involvements: {{ involvements }}.
+ Availability: {{ availability }}.
+ Interlocutor: {{ interlocutor }}.
+ TEXT
+
+ expected_message = <<~TEXT
+ Hello John,
+ Your email is #{other_user.email}, and your phone number is #{other_user.phone}.
+ You live in and your ID is #{other_user.uuid}.
+ Default neighborhood: .
+ Your interests: .
+ Your involvements: .
+ Availability: .
+ Interlocutor: Alice.
+ TEXT
+
+ result = ChatMessage.interpolate(message: message, user: other_user, author: author)
+ expect(result.strip).to eq(expected_message.strip)
+ end
+
+ it 'handles missing placeholders gracefully when author is nil' do
+ message = "Hello {{ first_name }}, your interlocutor is {{ interlocutor }}."
+ expected_message = "Hello John, your interlocutor is ."
+
+ result = ChatMessage.interpolate(message: message, user: user, author: nil)
+ expect(result).to eq(expected_message)
+ end
+
+ it 'handles missing placeholders in the message' do
+ message = "This message has no placeholders."
+ expected_message = "This message has no placeholders."
+
+ result = ChatMessage.interpolate(message: message, user: user)
+ expect(result).to eq(expected_message)
+ end
+ end
+
describe "custom type" do
let(:entourage) { create :entourage }
let!(:group) { create :entourage, uuid_v2: "uuid-123" }
From d69dd66764a0f01778d9597ea47cc46445ef287a Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Thu, 5 Dec 2024 10:22:11 +0100
Subject: [PATCH 02/38] EN-7825 sort lists to get expected rspec
---
app/models/chat_message.rb | 4 ++--
spec/models/chat_message_spec.rb | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/app/models/chat_message.rb b/app/models/chat_message.rb
index 468e8dbef..0a7a74e71 100644
--- a/app/models/chat_message.rb
+++ b/app/models/chat_message.rb
@@ -94,8 +94,8 @@ def interpolate message:, user:, author: nil
.gsub(/\{\{\s*city\s*\}\}/, user.city.to_s)
.gsub(/\{\{\s*uuid\s*\}\}/, user.uuid.to_s)
.gsub(/\{\{\s*default_neighborhood\s*\}\}/, user.default_neighborhood&.name.to_s)
- .gsub(/\{\{\s*interests\s*\}\}/, user.interest_i18n.join(', '))
- .gsub(/\{\{\s*involvements\s*\}\}/, user.involvement_i18n.join(', '))
+ .gsub(/\{\{\s*interests\s*\}\}/, user.interest_i18n.sort.join(', '))
+ .gsub(/\{\{\s*involvements\s*\}\}/, user.involvement_i18n.sort.join(', '))
.gsub(/\{\{\s*availability\s*\}\}/, user.availability_formatted.to_s)
.gsub(/\{\{\s*interlocutor\s*\}\}/, author&.first_name.to_s)
end
diff --git a/spec/models/chat_message_spec.rb b/spec/models/chat_message_spec.rb
index 61ae66ecb..53ef5452e 100644
--- a/spec/models/chat_message_spec.rb
+++ b/spec/models/chat_message_spec.rb
@@ -64,8 +64,8 @@
Your email is john.doe@example.com, and your phone number is +33612345678.
You live in Paris and your ID is 123e4567-e89b-12d3-a456-426614174000.
Default neighborhood: Groupe de Paris.
- Your interests: Jeux, Cuisine.
- Your involvements: Participer à des événements de convivialité, Apprendre avec des contenus pédagogiques.
+ Your interests: Cuisine, Jeux.
+ Your involvements: Apprendre avec des contenus pédagogiques, Participer à des événements de convivialité.
Availability: lundi : 09:00-12:00, 14:00-18:00
mardi : 10:00-12:00.
Interlocutor: Alice.
From ec2e559180e4bdea82e568c4e3b93ecadae288b5 Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Tue, 10 Dec 2024 14:30:23 +0100
Subject: [PATCH 03/38] EN-7825 fix interlocutor
---
app/models/chat_message.rb | 4 ++
spec/models/chat_message_spec.rb | 74 ++++++++++++++++++++++++++++++++
2 files changed, 78 insertions(+)
diff --git a/app/models/chat_message.rb b/app/models/chat_message.rb
index 0a7a74e71..39dcbd2ad 100644
--- a/app/models/chat_message.rb
+++ b/app/models/chat_message.rb
@@ -87,6 +87,10 @@ class << self
def interpolate message:, user:, author: nil
first_name = UserPresenter.format_first_name(user.first_name)
+ if message.match?(/\{\{\s*interlocutor\s*\}\}/)
+ author ||= ModerationServices.moderation_area_for_user_with_default(user)&.interlocutor_for_user(user)
+ end
+
message
.gsub(/\{\{\s*first_name\s*\}\}/, first_name.to_s)
.gsub(/\{\{\s*email\s*\}\}/, user.email.to_s)
diff --git a/spec/models/chat_message_spec.rb b/spec/models/chat_message_spec.rb
index 53ef5452e..3f9ae7c5d 100644
--- a/spec/models/chat_message_spec.rb
+++ b/spec/models/chat_message_spec.rb
@@ -106,6 +106,10 @@
message = "Hello {{ first_name }}, your interlocutor is {{ interlocutor }}."
expected_message = "Hello John, your interlocutor is ."
+ allow(ModerationServices).to receive(:moderation_area_for_user_with_default)
+ .with(user)
+ .and_return(double(interlocutor_for_user: nil))
+
result = ChatMessage.interpolate(message: message, user: user, author: nil)
expect(result).to eq(expected_message)
end
@@ -117,6 +121,76 @@
result = ChatMessage.interpolate(message: message, user: user)
expect(result).to eq(expected_message)
end
+
+ context 'when the message includes {{interlocutor}}' do
+ it 'handles a message with multiple placeholders including {{interlocutor}} with no moderation area' do
+ allow(ModerationServices).to receive(:moderation_area_for_user_with_default)
+ .with(user)
+ .and_return(nil)
+
+ message = "Hello {{ first_name }}, your interlocutor is {{ interlocutor }}."
+ expected_message = "Hello John, your interlocutor is ."
+
+ result = ChatMessage.interpolate(message: message, user: user, author: nil)
+ expect(result).to eq(expected_message)
+ end
+
+ it 'handles a message with multiple placeholders including {{interlocutor}} with no moderator' do
+ allow(ModerationServices).to receive(:moderation_area_for_user_with_default)
+ .with(user)
+ .and_return(double(interlocutor_for_user: nil))
+
+ message = "Hello {{ first_name }}, your interlocutor is {{ interlocutor }}."
+ expected_message = "Hello John, your interlocutor is ."
+
+ result = ChatMessage.interpolate(message: message, user: user, author: nil)
+ expect(result).to eq(expected_message)
+ end
+
+ it 'replaces {{interlocutor}} when an author is dynamically determined' do
+ allow(ModerationServices).to receive(:moderation_area_for_user_with_default)
+ .with(user)
+ .and_return(double(interlocutor_for_user: author))
+
+ message = "Your interlocutor is {{ interlocutor }}."
+ expected_message = "Your interlocutor is Alice."
+
+ result = ChatMessage.interpolate(message: message, user: user, author: nil)
+ expect(result).to eq(expected_message)
+ end
+
+ it 'replaces {{interlocutor}} when author is provided manually' do
+ message = "Your interlocutor is {{ interlocutor }}."
+ expected_message = "Your interlocutor is Alice."
+
+ result = ChatMessage.interpolate(message: message, user: user, author: author)
+ expect(result).to eq(expected_message)
+ end
+
+ it 'leaves {{interlocutor}} blank when no author and no default interlocutor are available' do
+ allow(ModerationServices).to receive(:moderation_area_for_user_with_default)
+ .with(user)
+ .and_return(double(interlocutor_for_user: nil))
+
+ message = "Your interlocutor is {{ interlocutor }}."
+ expected_message = "Your interlocutor is ."
+
+ result = ChatMessage.interpolate(message: message, user: user, author: nil)
+ expect(result).to eq(expected_message)
+ end
+
+ it 'handles a message with multiple placeholders including {{interlocutor}}' do
+ allow(ModerationServices).to receive(:moderation_area_for_user_with_default)
+ .with(user)
+ .and_return(double(interlocutor_for_user: author))
+
+ message = "Hello {{ first_name }}, your interlocutor is {{ interlocutor }}."
+ expected_message = "Hello John, your interlocutor is Alice."
+
+ result = ChatMessage.interpolate(message: message, user: user, author: nil)
+ expect(result).to eq(expected_message)
+ end
+ end
end
describe "custom type" do
From a9636cbe152a1571ec69c3794f765a406a9266f8 Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Wed, 4 Sep 2024 13:50:45 +0200
Subject: [PATCH 04/38] EN-7354 skeleton to compute matching using openai
assistants
---
Gemfile | 1 +
Gemfile.lock | 247 ++++++++++--------
Procfile | 2 +-
app/jobs/openai_assistant_job.rb | 47 ++++
app/models/action.rb | 3 +
app/models/concerns/matchable.rb | 41 +++
app/models/contribution.rb | 6 +-
app/models/entourage.rb | 2 +
app/models/matching.rb | 4 +
app/models/openai_assistant.rb | 11 +
app/models/solicitation.rb | 6 +-
app/services/entourage_services/matcher.rb | 169 ++++++++++++
...20240904090300_create_openai_assistants.rb | 20 ++
db/migrate/20240904090301_create_matchings.rb | 18 ++
spec/rails_helper.rb | 1 +
.../entourage_services/matcher_spec.rb | 39 +++
16 files changed, 498 insertions(+), 119 deletions(-)
create mode 100644 app/jobs/openai_assistant_job.rb
create mode 100644 app/models/concerns/matchable.rb
create mode 100644 app/models/matching.rb
create mode 100644 app/models/openai_assistant.rb
create mode 100644 app/services/entourage_services/matcher.rb
create mode 100644 db/migrate/20240904090300_create_openai_assistants.rb
create mode 100644 db/migrate/20240904090301_create_matchings.rb
create mode 100644 spec/services/entourage_services/matcher_spec.rb
diff --git a/Gemfile b/Gemfile
index 93939c82c..6abce919b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -30,6 +30,7 @@ gem 'simplify_rb', '~> 0'
gem 'lograge'
gem 'logstash-event'
gem 'aws-sdk-s3', '~> 1'
+gem 'ruby-openai'
gem 'faker'
gem 'activerecord-postgis-adapter', '~> 6.0'
gem 'slack-notifier'
diff --git a/Gemfile.lock b/Gemfile.lock
index 354621683..94e99c673 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -25,40 +25,40 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- actioncable (6.1.7.8)
- actionpack (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ actioncable (6.1.7.10)
+ actionpack (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
- actionmailbox (6.1.7.8)
- actionpack (= 6.1.7.8)
- activejob (= 6.1.7.8)
- activerecord (= 6.1.7.8)
- activestorage (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ actionmailbox (6.1.7.10)
+ actionpack (= 6.1.7.10)
+ activejob (= 6.1.7.10)
+ activerecord (= 6.1.7.10)
+ activestorage (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
mail (>= 2.7.1)
- actionmailer (6.1.7.8)
- actionpack (= 6.1.7.8)
- actionview (= 6.1.7.8)
- activejob (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ actionmailer (6.1.7.10)
+ actionpack (= 6.1.7.10)
+ actionview (= 6.1.7.10)
+ activejob (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
- actionpack (6.1.7.8)
- actionview (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ actionpack (6.1.7.10)
+ actionview (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
- actiontext (6.1.7.8)
- actionpack (= 6.1.7.8)
- activerecord (= 6.1.7.8)
- activestorage (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ actiontext (6.1.7.10)
+ actionpack (= 6.1.7.10)
+ activerecord (= 6.1.7.10)
+ activestorage (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
nokogiri (>= 1.8.5)
- actionview (6.1.7.8)
- activesupport (= 6.1.7.8)
+ actionview (6.1.7.10)
+ activesupport (= 6.1.7.10)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
@@ -68,25 +68,25 @@ GEM
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
- activejob (6.1.7.8)
- activesupport (= 6.1.7.8)
+ activejob (6.1.7.10)
+ activesupport (= 6.1.7.10)
globalid (>= 0.3.6)
- activemodel (6.1.7.8)
- activesupport (= 6.1.7.8)
- activerecord (6.1.7.8)
- activemodel (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ activemodel (6.1.7.10)
+ activesupport (= 6.1.7.10)
+ activerecord (6.1.7.10)
+ activemodel (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
activerecord-postgis-adapter (6.0.1)
activerecord (~> 6.0)
rgeo-activerecord (~> 6.0)
- activestorage (6.1.7.8)
- actionpack (= 6.1.7.8)
- activejob (= 6.1.7.8)
- activerecord (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ activestorage (6.1.7.10)
+ actionpack (= 6.1.7.10)
+ activejob (= 6.1.7.10)
+ activerecord (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
- activesupport (6.1.7.8)
+ activesupport (6.1.7.10)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -110,23 +110,23 @@ GEM
encryptor (~> 3.0.0)
attr_extras (7.1.0)
aws-eventstream (1.3.0)
- aws-partitions (1.971.0)
- aws-sdk-core (3.203.0)
+ aws-partitions (1.1001.0)
+ aws-sdk-core (3.211.0)
aws-eventstream (~> 1, >= 1.3.0)
- aws-partitions (~> 1, >= 1.651.0)
+ aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
- aws-sdk-kms (1.89.0)
- aws-sdk-core (~> 3, >= 3.203.0)
+ aws-sdk-kms (1.95.0)
+ aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.160.0)
- aws-sdk-core (~> 3, >= 3.203.0)
+ aws-sdk-s3 (1.169.0)
+ aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
- aws-sdk-sns (1.83.0)
- aws-sdk-core (~> 3, >= 3.203.0)
+ aws-sdk-sns (1.89.0)
+ aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
- aws-sigv4 (1.9.1)
+ aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9)
multi_json (~> 1)
@@ -141,7 +141,7 @@ GEM
uniform_notifier (~> 1.11)
case_transform (0.2)
activesupport
- chartkick (5.1.0)
+ chartkick (5.1.2)
chronic (0.10.2)
coffee-rails (5.0.0)
coffee-script (>= 2.2.0)
@@ -159,7 +159,7 @@ GEM
csv (3.3.0)
datadog-ci (0.8.3)
msgpack
- date (3.3.4)
+ date (3.4.0)
ddtrace (1.23.3)
datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1)
@@ -168,19 +168,20 @@ GEM
msgpack
debase-ruby_core_source (3.3.1)
diff-lcs (1.5.1)
- dotenv (3.1.2)
- dotenv-rails (3.1.2)
- dotenv (= 3.1.2)
+ dotenv (3.1.4)
+ dotenv-rails (3.1.4)
+ dotenv (= 3.1.4)
railties (>= 6.1)
encryptor (3.0.0)
erubi (1.13.0)
- execjs (2.9.1)
+ event_stream_parser (1.0.0)
+ execjs (2.10.0)
factory_bot (4.11.1)
activesupport (>= 3.0.0)
factory_bot_rails (4.11.1)
factory_bot (~> 4.11.1)
railties (>= 3.0.0)
- faker (3.4.2)
+ faker (3.5.1)
i18n (>= 1.8.11, < 2)
fakeredis (0.9.2)
redis (~> 4.8)
@@ -192,18 +193,18 @@ GEM
multipart-post (~> 2)
faraday-net_http (3.1.1)
net-http
- faraday-net_http_persistent (2.1.0)
+ faraday-net_http_persistent (2.3.0)
faraday (~> 2.5)
- net-http-persistent (~> 4.0)
+ net-http-persistent (>= 4.0.4, < 5)
ffi (1.16.3)
geocoder (1.8.3)
base64 (>= 0.1.0)
csv (>= 3.0.0)
globalid (1.2.1)
activesupport (>= 6.1)
- google-cloud-env (2.2.0)
+ google-cloud-env (2.2.1)
faraday (>= 1.0, < 3.a)
- googleauth (1.11.0)
+ googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
@@ -222,10 +223,11 @@ GEM
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
- i18n (1.14.5)
+ i18n (1.14.6)
concurrent-ruby (~> 1.0)
- icalendar (2.10.2)
+ icalendar (2.10.3)
ice_cube (~> 0.16)
+ ostruct
ice_cube (0.17.0)
jmespath (1.6.2)
jquery-rails (4.6.0)
@@ -234,11 +236,11 @@ GEM
thor (>= 0.14, < 2.0)
jquery-ui-rails (5.0.5)
railties (>= 3.2.16)
- json (2.7.2)
+ json (2.7.6)
json-schema (2.8.1)
addressable (>= 2.4)
jsonapi-renderer (0.2.2)
- jwt (2.8.2)
+ jwt (2.9.3)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -254,15 +256,25 @@ GEM
kaminari-core (1.2.2)
language_server-protocol (3.17.0.3)
libdatadog (7.0.0.1.0)
+ libdatadog (7.0.0.1.0-aarch64-linux)
+ libdatadog (7.0.0.1.0-x86_64-linux)
libddwaf (1.14.0.0.0)
ffi (~> 1.0)
+ libddwaf (1.14.0.0.0-aarch64-linux)
+ ffi (~> 1.0)
+ libddwaf (1.14.0.0.0-arm64-darwin)
+ ffi (~> 1.0)
+ libddwaf (1.14.0.0.0-x86_64-darwin)
+ ffi (~> 1.0)
+ libddwaf (1.14.0.0.0-x86_64-linux)
+ ffi (~> 1.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
logstash-event (1.2.02)
- loofah (2.22.0)
+ loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -279,22 +291,21 @@ GEM
method_source (1.1.0)
mini_magick (5.0.1)
mini_mime (1.1.5)
- mini_portile2 (2.8.7)
minitest (5.25.1)
momentjs-rails (2.29.4.1)
railties (>= 3.1)
- msgpack (1.7.2)
+ msgpack (1.7.3)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.4.1)
mustache (1.1.1)
net-http (0.4.1)
uri
- net-http-persistent (4.0.2)
+ net-http-persistent (4.0.4)
connection_pool (~> 2.2)
net-http2 (0.18.5)
http-2 (~> 0.11)
- net-imap (0.4.16)
+ net-imap (0.5.0)
date
net-protocol
net-pop (0.1.2)
@@ -309,46 +320,56 @@ GEM
zeitwerk (~> 2, >= 2.2)
nexmo-jwt (0.1.2)
jwt (~> 2)
- nio4r (2.7.3)
- nokogiri (1.16.7)
- mini_portile2 (~> 2.8.2)
+ nio4r (2.7.4)
+ nokogiri (1.16.7-aarch64-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.7-arm-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.7-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.16.7-x86-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.7-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4)
openssl (3.2.0)
optimist (3.1.0)
os (1.1.4)
+ ostruct (0.6.0)
parallel (1.26.3)
- parser (3.3.5.0)
+ parser (3.3.6.0)
ast (~> 2.4.1)
racc
patience_diff (1.2.0)
optimist (~> 3.0)
- pg (1.5.7)
- phonelib (0.9.1)
+ pg (1.5.9)
+ phonelib (0.9.3)
polylines (0.4.0)
public_suffix (6.0.1)
- puma (6.4.2)
+ puma (6.4.3)
nio4r (~> 2.0)
racc (1.8.1)
- rack (2.2.9)
+ rack (2.2.10)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.7.0)
- rails (6.1.7.8)
- actioncable (= 6.1.7.8)
- actionmailbox (= 6.1.7.8)
- actionmailer (= 6.1.7.8)
- actionpack (= 6.1.7.8)
- actiontext (= 6.1.7.8)
- actionview (= 6.1.7.8)
- activejob (= 6.1.7.8)
- activemodel (= 6.1.7.8)
- activerecord (= 6.1.7.8)
- activestorage (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ rails (6.1.7.10)
+ actioncable (= 6.1.7.10)
+ actionmailbox (= 6.1.7.10)
+ actionmailer (= 6.1.7.10)
+ actionpack (= 6.1.7.10)
+ actiontext (= 6.1.7.10)
+ actionview (= 6.1.7.10)
+ activejob (= 6.1.7.10)
+ activemodel (= 6.1.7.10)
+ activerecord (= 6.1.7.10)
+ activestorage (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
bundler (>= 1.15.0)
- railties (= 6.1.7.8)
+ railties (= 6.1.7.10)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
@@ -368,9 +389,9 @@ GEM
rails_stdout_logging
rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5)
- railties (6.1.7.8)
- actionpack (= 6.1.7.8)
- activesupport (= 6.1.7.8)
+ railties (6.1.7.10)
+ actionpack (= 6.1.7.10)
+ activesupport (= 6.1.7.10)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -391,7 +412,7 @@ GEM
faraday-net_http (< 4.0.0)
hashie (>= 1.2.0, < 6.0)
jwt (>= 1.5.6)
- rexml (3.3.7)
+ rexml (3.3.9)
rgeo (3.0.1)
rgeo-activerecord (6.2.2)
activerecord (>= 5.0)
@@ -400,12 +421,12 @@ GEM
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
- rspec-core (3.13.1)
+ rspec-core (3.13.2)
rspec-support (~> 3.13.0)
- rspec-expectations (3.13.2)
+ rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-mocks (3.13.1)
+ rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (6.1.5)
@@ -421,7 +442,7 @@ GEM
activesupport (>= 3.0.0)
mustache (~> 1.0, >= 0.99.4)
rspec (~> 3.0)
- rubocop (1.66.1)
+ rubocop (1.68.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
@@ -431,15 +452,19 @@ GEM
rubocop-ast (>= 1.32.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
- rubocop-ast (1.32.3)
+ rubocop-ast (1.34.0)
parser (>= 3.3.1.0)
- rubocop-rails (2.26.0)
+ rubocop-rails (2.27.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
- rubocop-rspec (3.0.4)
+ rubocop-rspec (3.2.0)
rubocop (~> 1.61)
+ ruby-openai (7.3.1)
+ event_stream_parser (>= 0.3.0, < 2.0.0)
+ faraday (>= 1)
+ faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-stemmer (3.0.0)
safety_mailer (0.0.10)
@@ -470,7 +495,7 @@ GEM
multi_json (~> 1.10)
simplify_rb (0.4.0)
slack-notifier (2.4.0)
- sorbet-runtime (0.5.11557)
+ sorbet-runtime (0.5.11637)
spring (2.1.1)
spring-commands-rspec (1.0.4)
spring (>= 0.9.1)
@@ -484,32 +509,32 @@ GEM
statsd-ruby (1.5.0)
store_attribute (1.3.1)
activerecord (>= 6.1)
- super_diff (0.12.1)
+ super_diff (0.13.0)
attr_extras (>= 6.2.4)
diff-lcs
patience_diff
- terser (1.2.3)
+ terser (1.2.4)
execjs (>= 0.3.0, < 3)
thor (1.3.2)
tilt (2.4.0)
timecop (0.9.10)
- timeout (0.4.1)
- tinymce-rails (7.3.0)
+ timeout (0.4.2)
+ tinymce-rails (7.5.0)
railties (>= 3.1.1)
turbolinks (5.2.1)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
- uglifier (4.2.0)
+ uglifier (4.2.1)
execjs (>= 0.3.0, < 3)
- unicode-display_width (2.5.0)
+ unicode-display_width (2.6.0)
uniform_notifier (1.16.0)
uri (0.13.1)
web-push (3.0.1)
jwt (~> 2.0)
openssl (~> 3.0)
- webmock (3.23.1)
+ webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -522,7 +547,12 @@ GEM
zeitwerk (2.6.18)
PLATFORMS
- ruby
+ aarch64-linux
+ arm-linux
+ arm64-darwin
+ x86-linux
+ x86_64-darwin
+ x86_64-linux
DEPENDENCIES
active_model_serializers (~> 0.10)
@@ -578,6 +608,7 @@ DEPENDENCIES
rspec_api_documentation
rubocop-rails
rubocop-rspec
+ ruby-openai
ruby-stemmer
safety_mailer
sass-rails
diff --git a/Procfile b/Procfile
index 8685cae06..aee929ede 100644
--- a/Procfile
+++ b/Procfile
@@ -1,4 +1,4 @@
web: bundle exec puma -C config/puma.rb
worker_1: bundle exec sidekiq -c ${SIDEKIQ_CONCURRENCY:-2} -q sms -q salesforce -q translation
-worker_2: bundle exec sidekiq -c ${SIDEKIQ_CONCURRENCY:-10} -q mailers -q default -q broadcast -q denorm
+worker_2: bundle exec sidekiq -c ${SIDEKIQ_CONCURRENCY:-10} -q mailers -q default -q openai_assistants -q broadcast -q denorm
release: ACTIVERECORD_STATEMENT_TIMEOUT=90s bundle exec rake db:migrate
diff --git a/app/jobs/openai_assistant_job.rb b/app/jobs/openai_assistant_job.rb
new file mode 100644
index 000000000..e875b1999
--- /dev/null
+++ b/app/jobs/openai_assistant_job.rb
@@ -0,0 +1,47 @@
+require 'json'
+
+class OpenaiAssistantJob
+ include Sidekiq::Worker
+
+ sidekiq_options :retry => false, queue: :openai_assistants
+
+ def perform openai_assistant_id
+ openai_assistant = OpenaiAssistant.find(openai_assistant_id)
+
+ return unless instance = openai_assistant.instance
+
+ EntourageServices::Matcher.new(instance: instance).find_best_matches do |on|
+ on.success do |matches|
+ openai_assistant.update_columns(
+ openai_assistant_id: matches['assistant_id'],
+ openai_thread_id: matches['thread_id'],
+ openai_run_id: matches['run_id'],
+ openai_message_id: matches['message_id'],
+ status: matches['status'],
+ run_ends_at: Time.current,
+ updated_at: Time.current
+ )
+
+ matches['matchings'].each_with_index do |matching, index|
+ next unless parse_matching = EntourageServices::Matcher.parse_matching(matching)
+
+ instance.matchings.build(match: parse_matching, score: matching['score'], position: index)
+ end
+
+ instance.save(validate: false)
+ end
+
+ on.failure do |error|
+ openai_assistant.update_columns(
+ status: :error,
+ run_ends_at: Time.current,
+ updated_at: Time.current
+ )
+ end
+ end
+ end
+
+ def self.perform_later openai_assistant_id
+ OpenaiAssistantJob.perform_async(openai_assistant_id)
+ end
+end
diff --git a/app/models/action.rb b/app/models/action.rb
index 02f0b30c8..73f37d5fc 100644
--- a/app/models/action.rb
+++ b/app/models/action.rb
@@ -1,4 +1,7 @@
class Action < Entourage
+ include Actionable
+ include Matchable
+ include Recommandable
include Sectionable
default_scope { where(group_type: :action, entourage_type: [:ask_for_help, :contribution]).order(created_at: :desc) }
diff --git a/app/models/concerns/matchable.rb b/app/models/concerns/matchable.rb
new file mode 100644
index 000000000..eba0dbe60
--- /dev/null
+++ b/app/models/concerns/matchable.rb
@@ -0,0 +1,41 @@
+module Matchable
+ extend ActiveSupport::Concern
+
+ included do
+ after_save :match_on_save, :if => :matchable_field_changed?
+
+ has_one :openai_assistant, as: :instance
+ has_many :matchings, as: :instance
+ has_many :matches, through: :matchings, source: :match
+ end
+
+ def matchable_field_changed?
+ previous_changes.slice(:title, :name, :description).present?
+ end
+
+ def match
+ @match ||= MatchStruct.new(instance: self)
+ end
+
+ def match_on_save
+ match.on_save
+ end
+
+ MatchStruct = Struct.new(:instance) do
+ def initialize instance: nil
+ @instance = instance
+ end
+
+ def on_save
+ ensure_openai_assistant_exists!
+ end
+
+ def ensure_openai_assistant_exists!
+ return if @instance.openai_assistant && @instance.openai_assistant.persisted?
+
+ openai_assistant = (@instance.openai_assistant || @instance.build_openai_assistant)
+ openai_assistant.instance_type = @instance.class.name # forces "Action" rather than "Entourage"
+ openai_assistant.save!
+ end
+ end
+end
diff --git a/app/models/contribution.rb b/app/models/contribution.rb
index c1be4a9a9..85bdd364f 100644
--- a/app/models/contribution.rb
+++ b/app/models/contribution.rb
@@ -1,8 +1,4 @@
-class Contribution < Entourage
- include Actionable
- include Sectionable
- include Recommandable
-
+class Contribution < Action
CONTENT_TYPES = %w(image/jpeg)
BUCKET_PREFIX = "contributions"
diff --git a/app/models/entourage.rb b/app/models/entourage.rb
index a9ca040f4..fcde0f79e 100644
--- a/app/models/entourage.rb
+++ b/app/models/entourage.rb
@@ -143,6 +143,8 @@ class Entourage < ApplicationRecord
after_create :check_moderation
+ alias_attribute :name, :title
+
def create_from_join_requests!
ApplicationRecord.connection.transaction do
participations = self.join_requests.to_a
diff --git a/app/models/matching.rb b/app/models/matching.rb
new file mode 100644
index 000000000..e6a7c4eae
--- /dev/null
+++ b/app/models/matching.rb
@@ -0,0 +1,4 @@
+class Matching < ApplicationRecord
+ belongs_to :instance, polymorphic: true
+ belongs_to :match, polymorphic: true
+end
diff --git a/app/models/openai_assistant.rb b/app/models/openai_assistant.rb
new file mode 100644
index 000000000..93aba59bb
--- /dev/null
+++ b/app/models/openai_assistant.rb
@@ -0,0 +1,11 @@
+class OpenaiAssistant < ApplicationRecord
+ belongs_to :instance, polymorphic: true
+
+ after_commit :run, on: :create
+
+ attr_accessor :forced_matching
+
+ def run
+ OpenaiAssistantJob.perform_later(id)
+ end
+end
diff --git a/app/models/solicitation.rb b/app/models/solicitation.rb
index f58edb496..52f71b96a 100644
--- a/app/models/solicitation.rb
+++ b/app/models/solicitation.rb
@@ -1,8 +1,4 @@
-class Solicitation < Entourage
- include Actionable
- include Sectionable
- include Recommandable
-
+class Solicitation < Action
default_scope {
where(group_type: :action, entourage_type: :ask_for_help)
.order(created_at: :desc)
diff --git a/app/services/entourage_services/matcher.rb b/app/services/entourage_services/matcher.rb
new file mode 100644
index 000000000..05e13ec1f
--- /dev/null
+++ b/app/services/entourage_services/matcher.rb
@@ -0,0 +1,169 @@
+module EntourageServices
+ class Matcher
+ PER = 25
+
+ attr_reader :callback, :instance, :user
+
+ class MatcherCallback < Callback
+ end
+
+ def initialize instance:
+ @callback = MatcherCallback.new
+ @instance = instance
+ @user = instance.user
+ end
+
+ def find_best_matches
+ yield callback if block_given?
+
+ matches = Client.new.find(instance: instance, contents: find_close_to_instance)
+
+ return callback.on_failure.try(:call, "No matches found") unless matches["success"]
+
+ callback.on_success.try(:call, matches)
+ rescue => e
+ callback.on_failure.try(:call, e.message)
+ end
+
+ def find_close_to_instance
+ latitude = instance.latitude
+ longitude = instance.longitude
+
+ {
+ contributions: ActiveModel::Serializer::CollectionSerializer.new(
+ get_contributions,
+ serializer: ::V1::Matchings::ActionSerializer,
+ scope: { latitude: latitude, longitude: longitude }
+ ),
+ solicitations: ActiveModel::Serializer::CollectionSerializer.new(
+ get_solicitations,
+ serializer: ::V1::Matchings::ActionSerializer,
+ scope: { latitude: latitude, longitude: longitude }
+ ),
+ outings: ActiveModel::Serializer::CollectionSerializer.new(
+ get_outings,
+ serializer: ::V1::Matchings::OutingSerializer,
+ scope: { latitude: latitude, longitude: longitude }
+ ),
+ resources: ActiveModel::Serializer::CollectionSerializer.new(
+ get_resources,
+ serializer: ::V1::Matchings::ResourceSerializer
+ ),
+ pois: ActiveModel::Serializer::CollectionSerializer.new(
+ get_pois,
+ serializer: ::V1::Matchings::PoiSerializer,
+ scope: { latitude: latitude, longitude: longitude }
+ )
+ }
+ end
+
+ def get_contributions
+ ContributionServices::Finder.new(user, Hash.new).find_all.limit(PER)
+ end
+
+ def get_solicitations
+ SolicitationServices::Finder.new(user, Hash.new).find_all.limit(PER)
+ end
+
+ def get_outings
+ OutingsServices::Finder.new(user, Hash.new).find_all.limit(PER)
+ end
+
+ def get_resources
+ Resource.where(status: :active)
+ end
+
+ def get_pois
+ Poi.validated.around(instance.latitude, instance.longitude, user.travel_distance).limit(PER)
+ end
+
+ def self.parse_matching matching
+ return unless matching.is_a?(Hash)
+ return unless matching.key?("id")
+ return unless matching.key?("type")
+
+ klass = matching["type"].classify.constantize
+
+ return klass.find_by_id_or_uuid(matching["id"]) if klass.respond_to?(:find_by_id_or_uuid)
+
+ klass.find_by_id(matching["id"])
+ end
+ end
+
+ class Client
+ attr_reader :client, :assistant_id
+
+ def initialize
+ @client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
+ @assistant_id = ENV['OPENAI_API_ASSISTANT_ID']
+ end
+
+ def find instance:, contents: []
+ # create new thread
+ thread = client.threads.create
+
+ # create instance message
+ message = client.messages.create(thread_id: thread['id'], parameters: {
+ role: "assistant",
+ content: {
+ instance: {
+ name: instance.name,
+ description: instance.description,
+ uuid: instance.uuid_v2
+ },
+ contents: contents
+ }.to_json
+ })
+
+ # run the thread
+ run = client.runs.create(thread_id: thread['id'], parameters: {
+ assistant_id: assistant_id,
+ max_prompt_tokens: 1024,
+ max_completion_tokens: 256
+ })
+
+ # wait for completion
+ status_loop(thread['id'], run['id'])
+
+ # find the message
+ return { success: false } unless result = find_run_message(thread['id'], run['id'])
+ return { success: false } unless result['success']
+
+ result.merge({
+ "assistant_id" => assistant_id,
+ "thread_id" => thread['id'],
+ "run_id" => run['id'],
+ })
+ end
+
+ def status_loop thread_id, run_id
+ while true do
+ response = client.runs.retrieve(id: run_id, thread_id: thread_id)
+ status = response['status']
+
+ break if ['completed'].include?(status) # success
+ break if ['cancelled', 'failed', 'expired'].include?(status) # error
+ break if ['incomplete'].include?(status) # ???
+
+ sleep 1 if ['queued', 'in_progress', 'cancelling'].include?(status)
+ end
+ end
+
+ def find_run_message thread_id, run_id
+ run_steps = client.run_steps.list(thread_id: thread_id, run_id: run_id, parameters: { order: 'asc' })
+
+ new_message_ids = run_steps['data'].filter_map { |step|
+ if step['type'] == 'message_creation'
+ step.dig('step_details', "message_creation", "message_id")
+ end
+ }
+
+ return unless new_message_ids.any?
+ return unless message = client.messages.retrieve(id: new_message_ids.first, thread_id: thread_id)
+
+ JSON.parse(message['content'].first['text']['value']).merge({
+ "message_id" => new_message_ids.first
+ })
+ end
+ end
+end
diff --git a/db/migrate/20240904090300_create_openai_assistants.rb b/db/migrate/20240904090300_create_openai_assistants.rb
new file mode 100644
index 000000000..d7ca160b7
--- /dev/null
+++ b/db/migrate/20240904090300_create_openai_assistants.rb
@@ -0,0 +1,20 @@
+class CreateOpenaiAssistants < ActiveRecord::Migration[6.1]
+ def change
+ create_table :openai_assistants do |t|
+ t.string :instance_type, null: false
+ t.integer :instance_id, null: false
+
+ t.string :openai_assistant_id
+ t.string :openai_thread_id
+ t.string :openai_run_id
+ t.string :openai_message_id
+ t.string :status
+ t.timestamp :run_starts_at
+ t.timestamp :run_ends_at
+
+ t.timestamps null: false
+
+ t.index [:instance_type, :instance_id]
+ end
+ end
+end
diff --git a/db/migrate/20240904090301_create_matchings.rb b/db/migrate/20240904090301_create_matchings.rb
new file mode 100644
index 000000000..06402d31c
--- /dev/null
+++ b/db/migrate/20240904090301_create_matchings.rb
@@ -0,0 +1,18 @@
+class CreateMatchings < ActiveRecord::Migration[6.1]
+ def change
+ create_table :matchings do |t|
+ t.string :instance_type, null: false
+ t.integer :instance_id, null: false
+
+ t.string :match_type, null: false
+ t.integer :match_id, null: false
+
+ t.float :score
+ t.integer :position
+
+ t.timestamps null: false
+
+ t.index [:instance_type, :instance_id]
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 95bbde58b..c9024fff4 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -19,6 +19,7 @@
ActionMailer::Base.deliveries.clear
stub_request(:any, /.*api.mailjet.com.*/).to_return(status: 200, body: { id: 1 }.to_json, headers: {})
+ stub_request(:any, /.*api.openai.*/).to_return(status: 200, body: "{}", headers: {})
# deactivate slack_trace notifications
SlackServices::StackTrace.any_instance.stub(:notify).and_return(nil)
diff --git a/spec/services/entourage_services/matcher_spec.rb b/spec/services/entourage_services/matcher_spec.rb
new file mode 100644
index 000000000..10aa25afa
--- /dev/null
+++ b/spec/services/entourage_services/matcher_spec.rb
@@ -0,0 +1,39 @@
+require 'rails_helper'
+
+RSpec.describe EntourageServices::Matcher do
+ describe 'parse_matching' do
+ let(:valid_json) do
+ { "score" => 0.95, "type" => "action", "id" => "abc_uuid_v2" }
+ end
+
+ let(:invalid_json) { nil }
+
+ it 'returns an empty array when json is nil' do
+ expect(described_class.parse_matching(invalid_json)).to be_nil
+ end
+
+ it 'returns an empty array when json does not contain keys' do
+ json_without_success = { "foo" => [] }
+ expect(described_class.parse_matching(json_without_success)).to be_nil
+ end
+
+ it 'returns an empty array when json does not contain id key' do
+ json_without_matchings = { "type" => "action" }
+ expect(described_class.parse_matching(json_without_matchings)).to be_nil
+ end
+
+ it 'returns an empty array when json does not contain type key' do
+ json_without_matchings = { "id" => "abc_uuid_v2" }
+ expect(described_class.parse_matching(json_without_matchings)).to be_nil
+ end
+
+ it 'returns a list of matched objects when json is valid' do
+ solicitation_double = double('Solicitation')
+
+ expect(Action).to receive(:find_by_id_or_uuid).with('abc_uuid_v2').and_return(solicitation_double)
+
+ result = described_class.parse_matching(valid_json)
+ expect(result).to eq(solicitation_double)
+ end
+ end
+end
From e14a3ccb5d725ff72c71dbeac89b9ca5f24e7f33 Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Wed, 4 Sep 2024 15:09:28 +0200
Subject: [PATCH 05/38] EN-7354 send a notification on matching with position=0
---
app/models/notification_permission.rb | 12 ++++++++--
.../push_notification_trigger_observer.rb | 2 +-
app/services/push_notification_trigger.rb | 24 ++++++++++++++++++-
config/locales/entourage.fr.yml | 2 ++
4 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/app/models/notification_permission.rb b/app/models/notification_permission.rb
index f6072710b..d219d03b7 100644
--- a/app/models/notification_permission.rb
+++ b/app/models/notification_permission.rb
@@ -1,6 +1,6 @@
class NotificationPermission < ApplicationRecord
- INAPP_INSTANCES = %{neighborhood outing contribution solicitation user}
- PUSH_INSTANCES = %{neighborhood outing contribution solicitation conversation user}
+ INAPP_INSTANCES = %{neighborhood outing contribution solicitation user resource poi}
+ PUSH_INSTANCES = %{neighborhood outing contribution solicitation conversation user resource poi}
belongs_to :user
validates_presence_of :user
@@ -67,6 +67,14 @@ def action instance_id = nil
permissions["action"]
end
+ def resource instance_id = nil
+ true
+ end
+
+ def poi instance_id = nil
+ true
+ end
+
# setters
def neighborhood= accepted
permissions["neighborhood"] = ActiveModel::Type::Boolean.new.cast(accepted)
diff --git a/app/models/push_notification_trigger_observer.rb b/app/models/push_notification_trigger_observer.rb
index 010c12fdd..a664cb721 100644
--- a/app/models/push_notification_trigger_observer.rb
+++ b/app/models/push_notification_trigger_observer.rb
@@ -1,5 +1,5 @@
class PushNotificationTriggerObserver < ActiveRecord::Observer
- observe :translation, :entourage, :entourage_moderation, :join_request, :neighborhoods_entourage, :survey_response, :user_reaction
+ observe :translation, :entourage, :entourage_moderation, :join_request, :neighborhoods_entourage, :survey_response, :user_reaction, :matching
def after_save record
record.instance_variable_set(:@record_changes, record.saved_changes)
diff --git a/app/services/push_notification_trigger.rb b/app/services/push_notification_trigger.rb
index 99f539055..fbe694d6a 100644
--- a/app/services/push_notification_trigger.rb
+++ b/app/services/push_notification_trigger.rb
@@ -5,6 +5,7 @@ class PushNotificationTrigger
# :chat_message
# :join_request
# :neighborhoods_entourage
+ # :matching
I18nStruct = Struct.new(:i18n, :i18n_args, :instance, :field, :date, :text) do
def initialize(i18n: nil, i18n_args: [], instance: nil, field: nil, date: nil, text: nil)
@@ -25,7 +26,7 @@ def to lang
return @i18ns[lang] = I18n.t(@i18n, locale: lang) % args_to(lang) if @i18n.present?
if @instance.present? && @field.present?
- return @i18ns[lang] = @instance.send(@field) unless @instance.translation.present?
+ return @i18ns[lang] = @instance.send(@field) unless @instance.respond_to?(:translation) && @instance.translation.present?
return @i18ns[lang] = @instance.translation.translate(field: @field, lang: lang) || @instance.send(@field)
end
@@ -541,6 +542,27 @@ def survey_response_on_create
)
end
+ def matching_on_create
+ return unless @record.position == 0
+ return unless instance = @record.instance
+ return unless match = @record.match
+ return unless moderator_id = ModerationServices.moderator_for_user(instance.user)&.id || match&.user_id
+
+ notify(
+ sender_id: moderator_id,
+ referent: match,
+ instance: match,
+ users: [instance.user],
+ params: {
+ object: I18nStruct.new(i18n: 'push_notifications.matching.create'),
+ content: I18nStruct.new(instance: match, field: :name),
+ extra: {
+ tracking: :matching
+ }
+ }
+ )
+ end
+
# use params[:extra] to be compliant with v7
def notify sender_id:, referent:, instance:, users:, params: {}
notify_push(sender_id: sender_id, referent: referent, instance: instance, users: users, params: params)
diff --git a/config/locales/entourage.fr.yml b/config/locales/entourage.fr.yml
index 5fd414f42..ebfe9a444 100644
--- a/config/locales/entourage.fr.yml
+++ b/config/locales/entourage.fr.yml
@@ -388,6 +388,8 @@ fr:
create_section: "Un voisin recherche un %s"
survey_response:
create: "%s a répondu à votre sondage : %s"
+ matching:
+ create: "Ce contenu pourrait vous intéresser"
timeliner:
h1:
From 9b9d2bdcefc91b0e4f054990b575d1556811eebd Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Tue, 29 Oct 2024 17:02:30 +0100
Subject: [PATCH 06/38] EN-7354 use new assistant
---
app/jobs/openai_assistant_job.rb | 20 ++--
app/models/poi.rb | 2 +
app/services/matching_services/client.rb | 7 ++
app/services/matching_services/connect.rb | 130 +++++++++++++++++++++
app/services/matching_services/response.rb | 70 +++++++++++
5 files changed, 218 insertions(+), 11 deletions(-)
create mode 100644 app/services/matching_services/client.rb
create mode 100644 app/services/matching_services/connect.rb
create mode 100644 app/services/matching_services/response.rb
diff --git a/app/jobs/openai_assistant_job.rb b/app/jobs/openai_assistant_job.rb
index e875b1999..8474b257d 100644
--- a/app/jobs/openai_assistant_job.rb
+++ b/app/jobs/openai_assistant_job.rb
@@ -10,22 +10,20 @@ def perform openai_assistant_id
return unless instance = openai_assistant.instance
- EntourageServices::Matcher.new(instance: instance).find_best_matches do |on|
- on.success do |matches|
+ MatchingServices::Connect.new(instance: instance).perform do |on|
+ on.success do |response|
openai_assistant.update_columns(
- openai_assistant_id: matches['assistant_id'],
- openai_thread_id: matches['thread_id'],
- openai_run_id: matches['run_id'],
- openai_message_id: matches['message_id'],
- status: matches['status'],
+ openai_assistant_id: response.metadata[:assistant_id],
+ openai_thread_id: response.metadata[:thread_id],
+ openai_run_id: response.metadata[:run_id],
+ openai_message_id: response.metadata[:message_id],
+ status: nil,
run_ends_at: Time.current,
updated_at: Time.current
)
- matches['matchings'].each_with_index do |matching, index|
- next unless parse_matching = EntourageServices::Matcher.parse_matching(matching)
-
- instance.matchings.build(match: parse_matching, score: matching['score'], position: index)
+ response.each_recommandation do |matching, score, index|
+ instance.matchings.build(match: matching, score: score, position: index)
end
instance.save(validate: false)
diff --git a/app/models/poi.rb b/app/models/poi.rb
index 712f76b7b..750782fa5 100644
--- a/app/models/poi.rb
+++ b/app/models/poi.rb
@@ -58,6 +58,8 @@ def find_by_uuid uuid
find(uuid)
end
end
+
+ alias_method :find_by_id_or_uuid, :find_by_uuid
end
def uuid
diff --git a/app/services/matching_services/client.rb b/app/services/matching_services/client.rb
new file mode 100644
index 000000000..19f1b1c09
--- /dev/null
+++ b/app/services/matching_services/client.rb
@@ -0,0 +1,7 @@
+module MatchingServices
+ class Client
+ def self.session
+ OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
+ end
+ end
+end
diff --git a/app/services/matching_services/connect.rb b/app/services/matching_services/connect.rb
new file mode 100644
index 000000000..0f35b4911
--- /dev/null
+++ b/app/services/matching_services/connect.rb
@@ -0,0 +1,130 @@
+module MatchingServices
+ class Connect
+ attr_reader :client, :callback, :assistant_id, :instance, :user
+
+ class MatcherCallback < Callback
+ end
+
+ def initialize instance:
+ @callback = MatcherCallback.new
+
+ @client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
+ @assistant_id = ENV['OPENAI_API_ASSISTANT_ID_2']
+
+ @instance = instance
+ @user = instance.user
+ end
+
+ def perform
+ yield callback if block_given?
+
+ # create new thread
+ thread = client.threads.create
+
+ # create instance message
+ message = client.messages.create(thread_id: thread['id'], parameters: user_message)
+
+ # run the thread
+ run = client.runs.create(thread_id: thread['id'], parameters: {
+ assistant_id: assistant_id,
+ max_prompt_tokens: 1024*16,
+ max_completion_tokens: 1024
+ })
+
+ # wait for completion
+ status = status_loop(thread['id'], run['id'])
+
+ return callback.on_failure.try(:call, "Failure status #{status}") unless ['completed', 'requires_action'].include?(status)
+
+ response = Response.new(response: find_run_message(thread['id'], run['id']))
+
+ return callback.on_failure.try(:call, "Response not valid") unless response.valid?
+
+ callback.on_success.try(:call, response)
+ rescue => e
+ callback.on_failure.try(:call, e.message)
+ end
+
+ private
+
+ def status_loop thread_id, run_id
+ status = nil
+
+ while true do
+ response = client.runs.retrieve(id: run_id, thread_id: thread_id)
+ status = response['status']
+
+ break if ['completed'].include?(status) # success
+ break if ['requires_action'].include?(status) # success
+ break if ['cancelled', 'failed', 'expired'].include?(status) # error
+ break if ['incomplete'].include?(status) # ???
+
+ sleep 1 if ['queued', 'in_progress', 'cancelling'].include?(status)
+ end
+
+ status
+ end
+
+ def find_run_message(thread_id, run_id)
+ messages = client.messages.list(thread_id: thread_id)
+ messages['data'].find { |message| message['run_id'] == run_id && message['role'] == 'assistant' }
+ end
+
+ def user_message
+ instance_class = if instance.respond_to?(:action) && instance.action?
+ instance.contribution? ? 'contribution' : 'solicitation'
+ else
+ instance.class.name.camelize.downcase
+ end
+
+ {
+ role: "user",
+ content: [{
+ type: "text",
+ text: "I created a #{instance_class} \"#{instance.name}\" : #{instance.description}. What are the most relevant recommandations? The following text contains all the possible recommandations."
+ }, {
+ type: "text",
+ text: get_recommandations.to_json
+ }]
+ }
+ end
+
+ def get_recommandations
+ {
+ recommandations: {
+ contributions: get_contributions.pluck(:uuid_v2, :title, :description).map { |values| [:uuid_v2, :title, :description].zip(values).to_h },
+ solicitations: get_solicitations.pluck(:uuid_v2, :title, :description).map { |values| [:uuid_v2, :title, :description].zip(values).to_h },
+ outings: get_outings.pluck(:uuid_v2, :title, :description).map { |values| [:uuid_v2, :title, :description].zip(values).to_h },
+ resources: get_resources.pluck(:uuid_v2, :name).map { |values| [:uuid_v2, :name].zip(values).to_h },
+ pois: get_pois.pluck(:source_id, :name).map { |values| [:source_id, :name].zip(values).to_h },
+ }
+ }
+ end
+
+ def get_contributions
+ return [] if instance.is_a?(Entourage) && instance.contribution?
+
+ ContributionServices::Finder.new(user, Hash.new).find_all.limit(100)
+ end
+
+ def get_solicitations
+ return [] if instance.is_a?(Entourage) && instance.solicitation?
+
+ SolicitationServices::Finder.new(user, Hash.new).find_all.limit(100)
+ end
+
+ def get_outings
+ OutingsServices::Finder.new(user, Hash.new).find_all.limit(100)
+ end
+
+ def get_resources
+ Resource.where(status: :active)
+ end
+
+ def get_pois
+ return unless instance.respond_to?(:latitude) && instance.respond_to?(:longitude)
+
+ Poi.validated.around(instance.latitude, instance.longitude, user.travel_distance).limit(300)
+ end
+ end
+end
diff --git a/app/services/matching_services/response.rb b/app/services/matching_services/response.rb
new file mode 100644
index 000000000..bfcf795fc
--- /dev/null
+++ b/app/services/matching_services/response.rb
@@ -0,0 +1,70 @@
+module MatchingServices
+ # response example
+ # {"recommandations"=>
+ # [{
+ # "type"=>"resource",
+ # "id"=>"e8bWJqPHAcxY",
+ # "name"=>"Sophie : les portraits des bénévoles",
+ # "score"=>"0.96",
+ # "explanation"=>"Ce ressource présente des histoires de bénévoles et peut vous inspirer pour obtenir de l'aide."
+ # }]
+ # }
+
+ Response = Struct.new(:response) do
+ TYPES = %w{contribution solicitation outing resource poi}
+
+ def initialize(response: nil)
+ @response = response
+ @parsed_response = parsed_response
+ end
+
+ def valid?
+ recommandations.any?
+ end
+
+ def parsed_response
+ return unless content = @response["content"]
+ return unless content.any? && first_content = content[0]
+ return unless first_content["type"] == "text"
+
+ # escape potential rendering strings
+ JSON.parse(first_content["text"]["value"].strip.gsub("```json", "").gsub("`", ""))
+ end
+
+ def recommandations
+ return [] unless @parsed_response
+
+ @parsed_response["recommandations"]
+ end
+
+ def metadata
+ {
+ message_id: @response["id"],
+ assistant_id: @response["assistant_id"],
+ thread_id: @response["thread_id"],
+ run_id: @response["run_id"]
+ }
+ end
+
+ def best_recommandation
+ each_recommandation do |instance, score, explanation, index|
+ return {
+ instance: instance,
+ score: score,
+ explanation: explanation,
+ index: index,
+ }
+ end
+ end
+
+ def each_recommandation &block
+ recommandations.each_with_index do |recommandation, index|
+ next unless recommandation["id"]
+ next unless TYPES.include?(recommandation["type"])
+ next unless instance = recommandation["type"].classify.constantize.find_by_id_or_uuid(recommandation["id"])
+
+ yield(instance, recommandation["score"], recommandation["explanation"], index)
+ end
+ end
+ end
+end
From 4219713ff0072bd838b551e7bbd086d143c61034 Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Mon, 4 Nov 2024 11:41:27 +0100
Subject: [PATCH 07/38] EN-7354 remove pois, resources from recommandations:
instead, we use json files in assistant configuration
---
app/services/matching_services/connect.rb | 14 +-------------
1 file changed, 1 insertion(+), 13 deletions(-)
diff --git a/app/services/matching_services/connect.rb b/app/services/matching_services/connect.rb
index 0f35b4911..353a3ab59 100644
--- a/app/services/matching_services/connect.rb
+++ b/app/services/matching_services/connect.rb
@@ -94,9 +94,7 @@ def get_recommandations
recommandations: {
contributions: get_contributions.pluck(:uuid_v2, :title, :description).map { |values| [:uuid_v2, :title, :description].zip(values).to_h },
solicitations: get_solicitations.pluck(:uuid_v2, :title, :description).map { |values| [:uuid_v2, :title, :description].zip(values).to_h },
- outings: get_outings.pluck(:uuid_v2, :title, :description).map { |values| [:uuid_v2, :title, :description].zip(values).to_h },
- resources: get_resources.pluck(:uuid_v2, :name).map { |values| [:uuid_v2, :name].zip(values).to_h },
- pois: get_pois.pluck(:source_id, :name).map { |values| [:source_id, :name].zip(values).to_h },
+ outings: get_outings.pluck(:uuid_v2, :title, :description).map { |values| [:uuid_v2, :title, :description].zip(values).to_h }
}
}
end
@@ -116,15 +114,5 @@ def get_solicitations
def get_outings
OutingsServices::Finder.new(user, Hash.new).find_all.limit(100)
end
-
- def get_resources
- Resource.where(status: :active)
- end
-
- def get_pois
- return unless instance.respond_to?(:latitude) && instance.respond_to?(:longitude)
-
- Poi.validated.around(instance.latitude, instance.longitude, user.travel_distance).limit(300)
- end
end
end
From 48787377b8d61e5ba454351059a7375aab30136d Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Mon, 4 Nov 2024 12:29:16 +0100
Subject: [PATCH 08/38] EN-7354 add explanation to matchings records
---
app/jobs/openai_assistant_job.rb | 4 ++--
db/migrate/20241104122500_add_explanation_to_matchings.rb | 5 +++++
2 files changed, 7 insertions(+), 2 deletions(-)
create mode 100644 db/migrate/20241104122500_add_explanation_to_matchings.rb
diff --git a/app/jobs/openai_assistant_job.rb b/app/jobs/openai_assistant_job.rb
index 8474b257d..095a6765d 100644
--- a/app/jobs/openai_assistant_job.rb
+++ b/app/jobs/openai_assistant_job.rb
@@ -22,8 +22,8 @@ def perform openai_assistant_id
updated_at: Time.current
)
- response.each_recommandation do |matching, score, index|
- instance.matchings.build(match: matching, score: score, position: index)
+ response.each_recommandation do |matching, score, explanation, index|
+ instance.matchings.build(match: matching, score: score, explanation: explanation, position: index)
end
instance.save(validate: false)
diff --git a/db/migrate/20241104122500_add_explanation_to_matchings.rb b/db/migrate/20241104122500_add_explanation_to_matchings.rb
new file mode 100644
index 000000000..85fa67df8
--- /dev/null
+++ b/db/migrate/20241104122500_add_explanation_to_matchings.rb
@@ -0,0 +1,5 @@
+class AddExplanationToMatchings < ActiveRecord::Migration[6.1]
+ def change
+ add_column :matchings, :explanation, :string, default: nil
+ end
+end
From bfef45bdcc91542775a3f1374a594a27dceaf1c9 Mon Sep 17 00:00:00 2001
From: nicolas-entourage <75681929+nicolas-entourage@users.noreply.github.com>
Date: Wed, 6 Nov 2024 14:13:21 +0100
Subject: [PATCH 09/38] EN-7354 add a menu to only list actions
---
app/controllers/admin/actions_controller.rb | 25 ++++++++
app/models/action.rb | 10 ++++
app/views/admin/actions/index.html.erb | 66 +++++++++++++++++++++
app/views/layouts/_admin_header.html.erb | 1 +
config/routes.rb | 14 +++++
5 files changed, 116 insertions(+)
create mode 100644 app/controllers/admin/actions_controller.rb
create mode 100644 app/views/admin/actions/index.html.erb
diff --git a/app/controllers/admin/actions_controller.rb b/app/controllers/admin/actions_controller.rb
new file mode 100644
index 000000000..c3cbbfceb
--- /dev/null
+++ b/app/controllers/admin/actions_controller.rb
@@ -0,0 +1,25 @@
+module Admin
+ class ActionsController < Admin::BaseController
+ layout 'admin_large'
+
+ def index
+ @params = params.permit([:area, :search]).to_h
+ @area = params[:area].presence&.to_sym || :all
+
+ @actions = Action.includes([:user, matchings: :match])
+ @actions = @actions.search_by(params[:search]) if params[:search].present?
+ @actions = @actions.with_moderation_area(@area.to_s) if @area && @area != :all
+ @actions = @actions.page(page).per(per)
+ end
+
+ private
+
+ def page
+ params[:page] || 1
+ end
+
+ def per
+ params[:per] || 25
+ end
+ end
+end
diff --git a/app/models/action.rb b/app/models/action.rb
index 73f37d5fc..bdd5b495b 100644
--- a/app/models/action.rb
+++ b/app/models/action.rb
@@ -6,6 +6,16 @@ class Action < Entourage
default_scope { where(group_type: :action, entourage_type: [:ask_for_help, :contribution]).order(created_at: :desc) }
+ scope :with_moderation_area, -> (moderation_area) {
+ if moderation_area.present? && moderation_area.to_sym == :hors_zone
+ return where("left(postal_code, 2) not in (?)", ModerationArea.only_departements).or(
+ where.not(country: :FR)
+ )
+ end
+
+ where("left(postal_code, 2) = ?", ModerationArea.departement(moderation_area)).where(country: :FR)
+ }
+
scope :filtered_with_user_profile, -> (user) {
return where(entourage_type: :contribution) if user.is_ask_for_help?
diff --git a/app/views/admin/actions/index.html.erb b/app/views/admin/actions/index.html.erb
new file mode 100644
index 000000000..748a009ad
--- /dev/null
+++ b/app/views/admin/actions/index.html.erb
@@ -0,0 +1,66 @@
+
+
+
Actions
+
+ <%= form_tag admin_actions_path, class: 'form-inline', method: :get do |f| %>
+
+
+
+ <%= text_field_tag :search, @params[:search], class: "form-control", placeholder: "Nom, description..." %>
+
+ <%= submit_tag "Chercher", class: "btn btn-default" %>
+
+
+
+
+
+ <% end %>
+
+
+ <% unless @actions.none? %>
+
+
+ Nom du groupe |
+ Créateur |
+ Code postal |
+ Date de création |
+ Statut |
+ Matchings |
+
+ <% @actions.each_with_index do |action, i| %>
+ <% parite = i.even? ? 'pair' : 'impair' %>
+
+
+ <%= link_to action.name, edit_admin_entourage_path(action) %> |
+ <%= link_to action.user.full_name, admin_user_path(action.user) %> |
+ <%= action.postal_code %> |
+ <%= l action.created_at, format: :date_short %> |
+ <%= status_label action %> |
+
+ <% action.matchings.each do |matching| %>
+
+ <%= "#{matching.match.name} : score #{matching.score} (#{matching.instance_type} : #{matching.explanation})" %>
+
+ <% end %>
+ |
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= page_entries_info @actions, entry_name: 'actions' %>
+
+ <%= paginate(@actions) %>
+
+
+
diff --git a/app/views/layouts/_admin_header.html.erb b/app/views/layouts/_admin_header.html.erb
index 24c0e6c14..db4875213 100644
--- a/app/views/layouts/_admin_header.html.erb
+++ b/app/views/layouts/_admin_header.html.erb
@@ -51,6 +51,7 @@