diff --git a/.gitignore b/.gitignore index 21e96def9..d76769206 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,6 @@ public/sitemap.xml public/assets/ +public/machine_learning/data/ public/system/ /public/ckeditor_assets/ diff --git a/app/assets/javascripts/admin/machine_learning/scripts.js b/app/assets/javascripts/admin/machine_learning/scripts.js new file mode 100644 index 000000000..49e9298e0 --- /dev/null +++ b/app/assets/javascripts/admin/machine_learning/scripts.js @@ -0,0 +1,15 @@ +(function() { + "use strict"; + App.AdminMachineLearningScripts = { + initialize: function() { + $(".admin .machine-learning-scripts select").on({ + change: function() { + var element = document.getElementById($(this).val()); + + $("#script_descriptions > *").not(element).addClass("hide"); + $(element).removeClass("hide"); + } + }).trigger("change"); + } + }; +}).call(this); diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 51a7a4d55..8ff6949cb 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -168,6 +168,7 @@ var initialize_modules = function() { App.ColumnsSelector.initialize(); } App.AdminBudgetsWizardCreationStep.initialize(); + App.AdminMachineLearningScripts.initialize(); App.BudgetEditAssociations.initialize(); App.Datepicker.initialize(); App.SDGRelatedListSelector.initialize(); diff --git a/app/assets/stylesheets/admin/machine_learning/help.scss b/app/assets/stylesheets/admin/machine_learning/help.scss new file mode 100644 index 000000000..5279a9e63 --- /dev/null +++ b/app/assets/stylesheets/admin/machine_learning/help.scss @@ -0,0 +1,7 @@ +.admin .machine-learning-help { + + > code { + display: block; + white-space: pre-wrap; + } +} diff --git a/app/assets/stylesheets/admin/machine_learning/scripts.scss b/app/assets/stylesheets/admin/machine_learning/scripts.scss new file mode 100644 index 000000000..ad9e738bc --- /dev/null +++ b/app/assets/stylesheets/admin/machine_learning/scripts.scss @@ -0,0 +1,60 @@ +.admin .machine-learning-scripts { + + .alert > :first-child { + @include has-fa-icon(ban, solid); + } + + .success > :first-child { + @include has-fa-icon(check-circle, solid); + } + + .warning > :first-child { + @include has-fa-icon(hourglass-half, solid); + } + + .alert, + .success, + .warning { + > :first-child::before { + margin-right: $font-icon-margin; + } + } + + dl { + font-size: $base-font-size; + + dt, + dd { + display: inline; + } + + * + dt::before { + content: ""; + display: block; + } + } + + form { + max-width: $global-width * 3 / 4; + } + + select { + max-width: 100%; + width: auto; + } + + [type=submit] { + @include regular-button; + display: block; + margin-bottom: 0; + margin-top: $line-height; + + &:focus { + outline: $outline-focus; + } + + &.cancel { + @include hollow-button($alert-color); + } + } +} diff --git a/app/assets/stylesheets/admin/machine_learning/setting.scss b/app/assets/stylesheets/admin/machine_learning/setting.scss new file mode 100644 index 000000000..d81b96210 --- /dev/null +++ b/app/assets/stylesheets/admin/machine_learning/setting.scss @@ -0,0 +1,68 @@ +.admin .machine-learning-setting { + + .card-divider { + background: $primary-color; + color: $white; + + h3 { + margin-top: 0; + } + } + + [aria-pressed] { + @include regular-button; + border-radius: $line-height; + font-weight: bold; + min-width: rem-calc(100); + position: relative; + + &:focus { + outline: $outline-focus; + } + + &::after { + background: $white; + border-radius: 100%; + content: ""; + display: block; + height: 1.75em; + position: absolute; + transform: translateY(-50%); + top: 50%; + width: 1.75em; + } + + &[aria-pressed=true] { + background: $primary-color; + padding-right: 2.5em; + text-align: left; + + &::after { + right: 0.5em; + } + } + + &[aria-pressed=false] { + background: $dark-gray; + padding-left: 2.5em; + text-align: right; + + &::after { + left: 0.5em; + } + } + } + + dl { + @include callout-size(map-get($callout-sizes, small)); + } + + dt { + font-weight: normal; + margin-bottom: 0; + } + + * + dt { + margin-top: $line-height; + } +} diff --git a/app/assets/stylesheets/admin/machine_learning/settings.scss b/app/assets/stylesheets/admin/machine_learning/settings.scss new file mode 100644 index 000000000..788cc95e6 --- /dev/null +++ b/app/assets/stylesheets/admin/machine_learning/settings.scss @@ -0,0 +1,24 @@ +.admin .machine-learning-settings { + + .settings-management { + $gap: rem-calc(map-get($grid-column-gutter, medium)); + display: flex; + flex-wrap: wrap; + margin-left: -$gap; + + > * { + margin-left: $gap; + flex-basis: calc((#{rem-calc(720)} - 100%) * 999); + flex-grow: 1; + } + + .card-section { + display: flex; + flex-direction: column; + + > :last-child { + margin-top: auto; + } + } + } +} diff --git a/app/assets/stylesheets/admin/machine_learning/show.scss b/app/assets/stylesheets/admin/machine_learning/show.scss new file mode 100644 index 000000000..d0024725f --- /dev/null +++ b/app/assets/stylesheets/admin/machine_learning/show.scss @@ -0,0 +1,6 @@ +.admin { + + .experimental-feature { + @include has-fa-icon(flask, solid); + } +} diff --git a/app/assets/stylesheets/admin/menu.scss b/app/assets/stylesheets/admin/menu.scss index b0d72571e..f0d407add 100644 --- a/app/assets/stylesheets/admin/menu.scss +++ b/app/assets/stylesheets/admin/menu.scss @@ -108,6 +108,10 @@ &.users-link { @include icon(user, solid); } + + &.ml-link { + @include icon(brain, solid); + } } li { diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index ff9ea349f..f438ea3a2 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -40,6 +40,7 @@ @import "budgets/**/*"; @import "debates/**/*"; @import "layout/**/*"; +@import "machine_learning/**/*"; @import "proposals/**/*"; @import "relationable/**/*"; @import "sdg/**/*"; diff --git a/app/assets/stylesheets/machine_learning/comments_summary.scss b/app/assets/stylesheets/machine_learning/comments_summary.scss new file mode 100644 index 000000000..e7d492d50 --- /dev/null +++ b/app/assets/stylesheets/machine_learning/comments_summary.scss @@ -0,0 +1,7 @@ +.machine-learning-comments-summary { + border-bottom: 1px solid $medium-gray; + + + * { + margin-top: $line-height * 1.5; + } +} diff --git a/app/assets/stylesheets/machine_learning/info.scss b/app/assets/stylesheets/machine_learning/info.scss new file mode 100644 index 000000000..6638db0ee --- /dev/null +++ b/app/assets/stylesheets/machine_learning/info.scss @@ -0,0 +1,5 @@ +.machine-learning-info { + @include has-fa-icon(info-circle, solid); + float: left; + margin-right: $font-icon-margin; +} diff --git a/app/components/admin/machine_learning/help_component.html.erb b/app/components/admin/machine_learning/help_component.html.erb new file mode 100644 index 000000000..bc1fc5342 --- /dev/null +++ b/app/components/admin/machine_learning/help_component.html.erb @@ -0,0 +1,25 @@ +
<%= t("admin.machine_learning.help.description_1") %>
+ +<%= t("admin.machine_learning.help.description_2") %>
+ +<%= t("admin.machine_learning.help.description_3") %>
+ +<%= t("admin.machine_learning.help.description_5") %>
+<%= instructions %>
++ <%= t("admin.machine_learning.notice.error") %> +
+ ++ <%= t("admin.machine_learning.notice.working") %> +
+ +<%= t("admin.machine_learning.#{kind}_description") %>
+ + <% if ml_info.present? %> + <%= form_for(setting, url: admin_setting_path(setting), method: :put) do |f| %> + <%= f.hidden_field :tab, value: "#settings", id: "setting_tab_#{kind}" %> + <%= f.hidden_field :value, value: (setting.enabled? ? "" : "active"), id: "setting_value_#{kind}" %> + <%= f.button(t("shared.#{setting.enabled? ? "yes" : "no"}"), + "aria-labelledby": "machine_learning_#{kind}", + "aria-describedby": "machine_learning_#{kind}_description", + "aria-pressed": setting.enabled?) %> + <% end %> + +<%= t("admin.machine_learning.data_folder_content") %>
+ + <% filenames.each do |filename| %> + <%= filename %>+ <%= sanitize(t("admin.machine_learning.feature_disabled", + link: link_to(t("admin.machine_learning.feature_disabled_link"), + admin_settings_path(anchor: "tab-feature-flags")))) %> +
++ <%= t("mailers.machine_learning_error.text") %> +
+ ++ <%= link_to t("mailers.machine_learning_error.link"), admin_machine_learning_url, + style: "color: #2895F1; text-decoration:none;" %> +
++ <%= t("mailers.machine_learning_success.text") %> +
+ ++ <%= link_to t("mailers.machine_learning_success.link"), admin_machine_learning_url, + style: "color: #2895F1; text-decoration:none;" %> +
+public/machine_learning/scripts"
+ description_4c: "The input data to be used by the script will be created as JSON files in the folder ../data"
+ description_4d: "Any generated output should be placed in the folder ../data"
+ description_4e: "It is recommended to create an independent .ini text file with the settings of the script, with the same name of the script and readable by configparser. This file will facilitate settings change by the administrators."
+ description_4f: "It is recommended to include as the first line of the script an initial triple-quote string with some brief information about it and links to any relevant information. This string will be automatically shown in the Administration interface."
+ description_4g: "It is recommended to log the relevant information of the script in a .log text file, with the same name of the script."
+ title_5: "To use AI / Machine Learning scripts you need to have Python installed on your server"
+ description_5: "Here you can see the example instructions for an Ubuntu 18.04 server:"
+ help_text: "This functionality is experimental."
+ last_execution: "Last execution"
+ no_content: "No content generated yet."
+ notice:
+ success: "The last script has been executed successfully."
+ error: "An error has occurred. You can see the details below."
+ working: "The script is running. The administrator who executed it will receive an email when it is finished."
+ delete_generated_content: "Generated content has been successfully deleted."
+ output_files: "Output files:"
+ related_content: "Related content"
+ related_content_description: "Adds automatically generated related content to proposals and participatory budget projects."
+ script_info: "You will receive an email in %{email} when the script finishes running."
+ script_name: "Script name:"
+ select_script: "Select python script to execute"
+ started_at: "Started at:"
+ tab_help: "Help"
+ tab_scripts: "Execute script"
+ tab_settings: "Settings / Generated content"
+ tags: "Tags"
+ tags_description: "Generates automatic tags on all items that can be tagged on."
+ title: "AI / Machine learning"
diff --git a/config/locales/en/general.yml b/config/locales/en/general.yml
index a9abab348..94cd6bd3b 100644
--- a/config/locales/en/general.yml
+++ b/config/locales/en/general.yml
@@ -965,7 +965,11 @@ en:
enqueue_remote_translation: Translations have been correctly requested.
button: Translate page
tags:
- filter:
- more:
- one: "One more tag"
- other: "%{count} more tags"
+ list:
+ filter:
+ more:
+ one: "One more tag"
+ other: "%{count} more tags"
+ machine_learning:
+ comments_summary: "Comments summary"
+ info_text: "Content generated by AI / Machine Learning"
diff --git a/config/locales/en/mailers.yml b/config/locales/en/mailers.yml
index d012cdb71..552aa9831 100644
--- a/config/locales/en/mailers.yml
+++ b/config/locales/en/mailers.yml
@@ -78,6 +78,16 @@ en:
hi: Hi
new_comment_by: There is a new evaluation comment from %{commenter} to the budget investment %{investment}
commenter_info: "%{commenter}, %{time}:"
+ machine_learning_error:
+ link: "Visit Machine Learning panel"
+ subject: "Machine Learning - An error has occurred running the script"
+ text: "An error has occurred running the Machine Learning script."
+ title: "Machine Learning script"
+ machine_learning_success:
+ link: "Visit Machine Learning panel"
+ subject: "Machine Learning - Content has been generated successfully"
+ text: "Content has been generated successfully."
+ title: "Machine Learning script"
new_actions_notification_rake_created:
subject: "More news about your citizen proposal"
hi: "Hello %{name},"
diff --git a/config/locales/en/settings.yml b/config/locales/en/settings.yml
index c6a4fdd6b..fb6fe0613 100644
--- a/config/locales/en/settings.yml
+++ b/config/locales/en/settings.yml
@@ -106,6 +106,8 @@ en:
recommendations_on_proposals_description: "Displays recommendations to users on the proposals page based on the tags of the items followed"
community: "Community on proposals and investments"
community_description: "Enables the community section in the proposals and investment projects of the Participatory Budgets"
+ machine_learning: "AI / Machine learning"
+ machine_learning_description: "Enable the AI / Machine Learning section to run python scripts and enrich content automatically."
map: "Proposals and budget investments geolocation"
map_description: "Enables geolocation of proposals and investment projects"
allow_images: "Allow upload and show images"
diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml
index f3524df58..f27a7c35d 100644
--- a/config/locales/es/admin.yml
+++ b/config/locales/es/admin.yml
@@ -750,6 +750,7 @@ es:
proposals: "Propuestas"
polls: "Votaciones"
layouts: "Plantillas"
+ machine_learning: "IA / Machine Learning"
mailers: "Correos"
management: "Gestión"
welcome: "Bienvenido/a"
@@ -770,6 +771,7 @@ es:
debates: "Debates"
comments: "Comentarios"
local_census_records: Gestionar censo local
+ machine_learning: "IA / Machine learning"
administrators:
index:
title: Administradores
@@ -1682,3 +1684,53 @@ es:
created: Registros creados
local_census_records:
no_records_found: No se han encontrado registros.
+ machine_learning:
+ cancel: "Cancelar operación"
+ cancel_alert: "Esta acción cancelará el script actual y será necesario ejecutar el script de nuevo."
+ comments_summary: "Resumen de comentarios"
+ comments_summary_description: "Muestra un resumen de comentarios generado automáticamente en todos los elementos que pueden ser comentados."
+ data_folder_content: "Contenido carpeta data"
+ error: "Error:"
+ execute_script: "Ejecutar script"
+ executed_by: "Ejecutado por:"
+ executed_script: "Script ejecutado:"
+ feature_disabled: "Esta funcionalidad está deshabilitada. Para utilizar Machine Learning puedes habilitarla desde la %{link}."
+ feature_disabled_link: "página de configuración"
+ help:
+ title_1: "¿Qué es IA / Machine Learning?"
+ description_1: "Tradicionalmente, los programas informáticos se diseñan estableciendo reglas precisas sobre lo que debe hacer el programa en cada situación, y cómo procesar en cada momento la información disponible. Lo que se conoce comúnmente como IA / Machine Learning es un tipo de programas para los cuales lo que se establece de manera precisa es cuál es la tarea a realizar y cómo se valora el que la tarea esté mejor o peor realizada. Pero a diferencia de los otros, el sistema descubre o aprende cuál sería el método más adecuado para realizar dicha tarea."
+ title_2: "¿Qué hace el módulo de IA / Machine Learning en CONSUL?"
+ description_2: "Este módulo permite implementar en CONSUL cualquier tipo de programas de IA / Machine Learning. Los programas pueden procesar la información disponible en CONSUL y producir resultados que ayuden tanto a los usuarios como a los administradores a llevar a cabo una participación ciudadana más eficaz e inteligente. El módulo se ha desarrollado para que sea sencillo implementar y ejecutar nuevos programas, manteniendo por separado el código general de CONSUL y el código de estos nuevos programas."
+ title_3: "Cómo usar el módulo"
+ description_3: "Para usarlo por primera vez es necesario seguir las instrucciones disponibles en la documentación de CONSUL para activar correctamente este módulo. Una vez el módulo esté correctamente activado, los programas se ejecutan desde la pestaña \"Ejecutar scripts\". Antes de ejecutarlo podemos ver en esa misma pestaña una estimación del tiempo de ejecución y otras informaciones relevantes. Al terminar la ejecución podemos activar el contenido que se mostrará en CONSUL desde la pestaña de \"Configuración\", así como descargar otros archivos generados que nos puedan resultar útiles."
+ title_4: "Cómo implementar nuevos scripts de IA / Machine Learning."
+ description_4: "Utiliza los scripts existentes como ejemplo."
+ description_4b: "Los nuevos scripts deben estar ubicados en la carpeta public/machine_learning/scripts"
+ description_4c: "Los datos de entrada que utilizará el script se crearán como archivos JSON en la carpeta ../data"
+ description_4d: "Cualquier salida generada debe crearse en la carpeta ../data"
+ description_4e: "Se recomienda crear un archivo de texto .ini independiente con los parámetros de configuración del script, con el mismo nombre del script y legible mediante configparser. Este archivo facilitará cambios de configuración por parte de los administradores."
+ description_4f: "Se recomienda incluir como primera línea del script una cadena inicial de comillas triples con información breve sobre el mismo y enlaces a cualquier información relevante. Esta cadena se mostrará automáticamente en la interfaz de administración."
+ description_4g: "Se recomienda crear un log con la información relevante del script en un archivo de texto .log, con el mismo nombre del script."
+ title_5: "Para utilizar los scripts de IA / Machine Learning necesitas tener Python instalado en tu servidor"
+ description_5: "Aquí puedes ver las instrucciones de ejemplo para un servidor Ubuntu 18.04:"
+ help_text: "Esta funcionalidad es experimental."
+ last_execution: "Última ejecución"
+ no_content: "Todavía no se ha generado contenido."
+ notice:
+ success: "El último script se ha ejecutado correctamente."
+ error: "Se ha producido un error. Más información sobre el error a continuación."
+ working: "El script se está ejecutando. El administrador que lo ejecutó recibirá un email cuando termine."
+ delete_generated_content: "El contenido generado se ha borrado correctamente."
+ output_files: "Ficheros de salida:"
+ related_content: "Contenido relacionado"
+ related_content_description: "Añade contenido relacionado generado automáticamente a las propuestas y proyectos de presupuestos participativos."
+ script_info: "Recibirás un email en %{email} cuando el script termine de ejecutarse."
+ script_name: "Nombre del script:"
+ select_script: "Seleccione el script pyhton a ejecutar"
+ started_at: "Empezado a las:"
+ tab_help: "Ayuda"
+ tab_settings: "Configuración / Contenido generado"
+ tab_scripts: "Ejecutar script"
+ tags: "Etiquetas"
+ tags_description: "Genera etiquetas automáticas para todos los elementos que pueden ser etiquetados."
+ title: "IA / Machine learning"
diff --git a/config/locales/es/general.yml b/config/locales/es/general.yml
index fd9494e41..b6b1a1395 100644
--- a/config/locales/es/general.yml
+++ b/config/locales/es/general.yml
@@ -965,7 +965,11 @@ es:
enqueue_remote_translation: Se han solicitado correctamente las traducciones.
button: Traducir página
tags:
- filter:
- more:
- one: "Una etiqueta más"
- other: "%{count} etiquetas más"
+ list:
+ filter:
+ more:
+ one: "Una etiqueta más"
+ other: "%{count} etiquetas más"
+ machine_learning:
+ comments_summary: "Resumen de comentarios"
+ info_text: "Contenido generado mediante IA / Machine Learning"
diff --git a/config/locales/es/mailers.yml b/config/locales/es/mailers.yml
index 74122ddf2..ec4974a4d 100644
--- a/config/locales/es/mailers.yml
+++ b/config/locales/es/mailers.yml
@@ -78,6 +78,16 @@ es:
hi: Hola
new_comment_by: Hay un nuevo comentario de evaluación de %{commenter} en el presupuesto participativo %{investment}
commenter_info: "%{commenter}, %{time}"
+ machine_learning_error:
+ link: "Visitar el panel de Machine Learning"
+ subject: "Machine learning - Se ha producido un error"
+ text: "Se ha producido un error ejecutando el script de Machine Learning."
+ title: "Script Machine Learning"
+ machine_learning_success:
+ link: "Visitar el panel de Machine Learning"
+ subject: "Machine learning - Contenido generado correctamente"
+ text: "El contenido ha sido generado correctamente."
+ title: "Script Machine Learning"
new_actions_notification_rake_created:
subject: "Más novedades de tu propuesta ciudadana"
hi: "Hola %{name},"
diff --git a/config/locales/es/settings.yml b/config/locales/es/settings.yml
index 98c775cd3..4e2086216 100644
--- a/config/locales/es/settings.yml
+++ b/config/locales/es/settings.yml
@@ -106,6 +106,8 @@ es:
recommendations_on_proposals_description: "Muestra a los usuarios recomendaciones en la página de propuestas basado en las etiquetas de los elementos que sigue"
community: "Comunidad en propuestas y proyectos de gasto"
community_description: "Activa la sección de comunidad en las propuestas y en los proyectos de gasto de los Presupuestos participativos"
+ machine_learning: "IA / Machine learning"
+ machine_learning_description: "Habilita la sección de IA / Machine Learning para ejecutar scripts de python y enriquecer el contenido automáticamente."
map: "Geolocalización de propuestas y proyectos de gasto"
map_description: "Activa la geolocalización de propuestas y proyectos de gasto"
allow_images: "Permitir subir y mostrar imágenes"
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 8749178b1..4a358a0c7 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -270,6 +270,11 @@ namespace :admin do
namespace :local_census_records do
resources :imports, only: [:new, :create, :show]
end
+
+ resource :machine_learning, controller: :machine_learning, only: [:show] do
+ post :execute, on: :collection
+ delete :cancel, on: :collection
+ end
end
resolve "Milestone" do |milestone|
diff --git a/db/migrate/20210422104400_create_machine_learning_jobs.rb b/db/migrate/20210422104400_create_machine_learning_jobs.rb
new file mode 100644
index 000000000..37ff2b69a
--- /dev/null
+++ b/db/migrate/20210422104400_create_machine_learning_jobs.rb
@@ -0,0 +1,14 @@
+class CreateMachineLearningJobs < ActiveRecord::Migration[5.2]
+ def change
+ create_table :machine_learning_jobs do |t|
+ t.datetime :started_at
+ t.datetime :finished_at
+ t.string :script
+ t.integer :pid
+ t.string :error
+ t.references :user, foreign_key: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20210423114500_create_ml_summary_comments.rb b/db/migrate/20210423114500_create_ml_summary_comments.rb
new file mode 100644
index 000000000..4ece4cd86
--- /dev/null
+++ b/db/migrate/20210423114500_create_ml_summary_comments.rb
@@ -0,0 +1,11 @@
+class CreateMlSummaryComments < ActiveRecord::Migration[5.2]
+ def change
+ create_table :ml_summary_comments do |t|
+ t.integer :commentable_id
+ t.string :commentable_type
+ t.text :body
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20210424092400_add_machine_learning_fields_to_related_contents.rb b/db/migrate/20210424092400_add_machine_learning_fields_to_related_contents.rb
new file mode 100644
index 000000000..2193daa03
--- /dev/null
+++ b/db/migrate/20210424092400_add_machine_learning_fields_to_related_contents.rb
@@ -0,0 +1,6 @@
+class AddMachineLearningFieldsToRelatedContents < ActiveRecord::Migration[5.2]
+ def change
+ add_column :related_contents, :machine_learning, :boolean, default: false
+ add_column :related_contents, :machine_learning_score, :integer, default: 0
+ end
+end
diff --git a/db/migrate/20210519115700_create_machine_learning_infos.rb b/db/migrate/20210519115700_create_machine_learning_infos.rb
new file mode 100644
index 000000000..8866ce4d5
--- /dev/null
+++ b/db/migrate/20210519115700_create_machine_learning_infos.rb
@@ -0,0 +1,11 @@
+class CreateMachineLearningInfos < ActiveRecord::Migration[5.2]
+ def change
+ create_table :machine_learning_infos do |t|
+ t.string :kind
+ t.datetime :generated_at
+ t.string :script
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index b69a16a90..d02fe0d92 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2021_01_23_100638) do
+ActiveRecord::Schema.define(version: 2021_05_19_115700) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
@@ -875,6 +875,26 @@ ActiveRecord::Schema.define(version: 2021_01_23_100638) do
t.index ["user_id"], name: "index_locks_on_user_id"
end
+ create_table "machine_learning_infos", force: :cascade do |t|
+ t.string "kind"
+ t.datetime "generated_at"
+ t.string "script"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "machine_learning_jobs", force: :cascade do |t|
+ t.datetime "started_at"
+ t.datetime "finished_at"
+ t.string "script"
+ t.integer "pid"
+ t.string "error"
+ t.bigint "user_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["user_id"], name: "index_machine_learning_jobs_on_user_id"
+ end
+
create_table "managers", id: :serial, force: :cascade do |t|
t.integer "user_id"
t.index ["user_id"], name: "index_managers_on_user_id"
@@ -920,6 +940,14 @@ ActiveRecord::Schema.define(version: 2021_01_23_100638) do
t.index ["status_id"], name: "index_milestones_on_status_id"
end
+ create_table "ml_summary_comments", force: :cascade do |t|
+ t.integer "commentable_id"
+ t.string "commentable_type"
+ t.text "body"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "moderators", id: :serial, force: :cascade do |t|
t.integer "user_id"
t.index ["user_id"], name: "index_moderators_on_user_id"
@@ -1278,6 +1306,8 @@ ActiveRecord::Schema.define(version: 2021_01_23_100638) do
t.datetime "hidden_at"
t.integer "related_content_scores_count", default: 0
t.integer "author_id"
+ t.boolean "machine_learning", default: false
+ t.integer "machine_learning_score", default: 0
t.index ["child_relationable_type", "child_relationable_id"], name: "index_related_contents_on_child_relationable"
t.index ["hidden_at"], name: "index_related_contents_on_hidden_at"
t.index ["parent_relationable_id", "parent_relationable_type", "child_relationable_id", "child_relationable_type"], name: "unique_parent_child_related_content", unique: true
@@ -1704,6 +1734,7 @@ ActiveRecord::Schema.define(version: 2021_01_23_100638) do
add_foreign_key "legislation_draft_versions", "legislation_processes"
add_foreign_key "legislation_proposals", "legislation_processes"
add_foreign_key "locks", "users"
+ add_foreign_key "machine_learning_jobs", "users"
add_foreign_key "managers", "users"
add_foreign_key "moderators", "users"
add_foreign_key "notifications", "users"
diff --git a/public/machine_learning/scripts/budgets_related_content_and_tags_nmf.py b/public/machine_learning/scripts/budgets_related_content_and_tags_nmf.py
new file mode 100644
index 000000000..e02639be1
--- /dev/null
+++ b/public/machine_learning/scripts/budgets_related_content_and_tags_nmf.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python
+# coding: utf-8
+
+# In[1]:
+
+
+"""
+Related Participatory Budgeting projects and Tags - Dummy script
+
+"""
+
+
+# In[2]:
+
+
+data_path = '../data'
+config_file = 'budgets_related_content_and_tags_nmf.ini'
+logging_file ='budgets_related_content_and_tags_nmf.log'
+
+
+# In[3]:
+
+
+# Input file:
+inputjsonfile = 'budget_investments.json'
+
+# Output files:
+taggings_filename = 'ml_taggings_budgets.json'
+tags_filename = 'ml_tags_budgets.json'
+related_props_filename = 'ml_related_content_budgets.json'
+
+
+# In[4]:
+
+
+import os
+import pandas as pd
+
+
+# ### Read the proposals
+
+# In[5]:
+
+
+# proposals_input_df = pd.read_json(os.path.join(data_path,inputjsonfile),orient="records")
+# col_id = 'id'
+# cols_content = ['title','description']
+# proposals_input_df = proposals_input_df[[col_id]+cols_content]
+
+
+# ### Create file: Taggings. Each line is a Tag associated to a Proposal
+
+# In[6]:
+
+
+taggings_file_cols = ['tag_id','taggable_id','taggable_type']
+taggings_file_df = pd.DataFrame(columns=taggings_file_cols)
+row = [0,1,'Budget::Investment']
+taggings_file_df = taggings_file_df.append(dict(zip(taggings_file_cols,row)), ignore_index=True)
+taggings_file_df.to_json(os.path.join(data_path,taggings_filename),orient="records", force_ascii=False)
+
+
+# ### Create file: Tags. List of Tags with the number of times they have been used
+
+# In[7]:
+
+
+tags_file_cols = ['id','name','taggings_count','kind']
+tags_file_df = pd.DataFrame(columns=tags_file_cols)
+row = [0,'tag',0,'']
+tags_file_df = tags_file_df.append(dict(zip(tags_file_cols,row)), ignore_index=True)
+tags_file_df.to_json(os.path.join(data_path,tags_filename),orient="records", force_ascii=False)
+
+
+# ### Create file: List of related proposals
+
+# In[8]:
+
+
+numb_related_proposals = 2
+related_props_cols = ['id']+['related'+str(num) for num in range(1,numb_related_proposals+1)]
+related_props_df = pd.DataFrame(columns=related_props_cols)
+row = [1]+['' for num in range(1,numb_related_proposals+1)]
+related_props_df = related_props_df.append(dict(zip(related_props_cols,row)), ignore_index=True)
+related_props_df.to_json(os.path.join(data_path,related_props_filename),orient="records", force_ascii=False)
+
diff --git a/public/machine_learning/scripts/budgets_summary_comments_textrank.py b/public/machine_learning/scripts/budgets_summary_comments_textrank.py
new file mode 100644
index 000000000..9f81ad66d
--- /dev/null
+++ b/public/machine_learning/scripts/budgets_summary_comments_textrank.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python
+# coding: utf-8
+
+# In[1]:
+
+
+"""
+Participatory Budgeting comments summaries - Dummy script
+
+"""
+
+
+# In[2]:
+
+
+data_path = '../data'
+config_file = 'budgets_summary_comments_textrank.ini'
+logging_file ='budgets_summary_comments_textrank.log'
+
+
+# In[3]:
+
+
+# Input file:
+inputjsonfile = 'comments.json'
+
+# Output files:
+comments_summaries_filename = 'ml_comments_summaries_budgets.json'
+
+
+# In[4]:
+
+
+import os
+import pandas as pd
+
+
+# ### Read the comments
+
+# In[5]:
+
+
+# comments_input_df = pd.read_json(os.path.join(data_path,inputjsonfile),orient="records")
+# col_id = 'commentable_id'
+# col_content = 'body'
+# comments_input_df = comments_input_df[[col_id]+[col_content]]
+
+
+# ### Create file. Comments summaries
+
+# In[6]:
+
+
+comments_summaries_cols = ['id','commentable_id','commentable_type','body']
+comments_summaries_df = pd.DataFrame(columns=comments_summaries_cols)
+row = [0,0,'Budget::Investment','Summary']
+comments_summaries_df = comments_summaries_df.append(dict(zip(comments_summaries_cols,row)), ignore_index=True)
+comments_summaries_df.to_json(os.path.join(data_path,comments_summaries_filename),orient="records", force_ascii=False)
+
diff --git a/public/machine_learning/scripts/proposals_related_content_and_tags_nmf.py b/public/machine_learning/scripts/proposals_related_content_and_tags_nmf.py
new file mode 100644
index 000000000..75d2ef02b
--- /dev/null
+++ b/public/machine_learning/scripts/proposals_related_content_and_tags_nmf.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python
+# coding: utf-8
+
+# In[1]:
+
+
+"""
+Related Proposals and Tags - Dummy script
+
+"""
+
+
+# In[2]:
+
+
+data_path = '../data'
+config_file = 'proposals_related_content_and_tags_nmf.ini'
+logging_file ='proposals_related_content_and_tags_nmf.log'
+
+
+# In[3]:
+
+
+# Input file:
+inputjsonfile = 'proposals.json'
+
+# Output files:
+taggings_filename = 'ml_taggings_proposals.json'
+tags_filename = 'ml_tags_proposals.json'
+related_props_filename = 'ml_related_content_proposals.json'
+
+
+# In[4]:
+
+
+import os
+import pandas as pd
+
+
+# ### Read the proposals
+
+# In[5]:
+
+
+# proposals_input_df = pd.read_json(os.path.join(data_path,inputjsonfile),orient="records")
+# col_id = 'id'
+# cols_content = ['title','description','summary']
+# proposals_input_df = proposals_input_df[[col_id]+cols_content]
+
+
+# ### Create file: Taggings. Each line is a Tag associated to a Proposal
+
+# In[6]:
+
+
+taggings_file_cols = ['tag_id','taggable_id','taggable_type']
+taggings_file_df = pd.DataFrame(columns=taggings_file_cols)
+row = [0,1,'Proposal']
+taggings_file_df = taggings_file_df.append(dict(zip(taggings_file_cols,row)), ignore_index=True)
+taggings_file_df.to_json(os.path.join(data_path,taggings_filename),orient="records", force_ascii=False)
+
+
+# ### Create file: Tags. List of Tags with the number of times they have been used
+
+# In[7]:
+
+
+tags_file_cols = ['id','name','taggings_count','kind']
+tags_file_df = pd.DataFrame(columns=tags_file_cols)
+row = [0,'tag',0,'']
+tags_file_df = tags_file_df.append(dict(zip(tags_file_cols,row)), ignore_index=True)
+tags_file_df.to_json(os.path.join(data_path,tags_filename),orient="records", force_ascii=False)
+
+
+# ### Create file: List of related proposals
+
+# In[8]:
+
+
+numb_related_proposals = 2
+related_props_cols = ['id']+['related'+str(num) for num in range(1,numb_related_proposals+1)]
+related_props_df = pd.DataFrame(columns=related_props_cols)
+row = [1]+['' for num in range(1,numb_related_proposals+1)]
+related_props_df = related_props_df.append(dict(zip(related_props_cols,row)), ignore_index=True)
+related_props_df.to_json(os.path.join(data_path,related_props_filename),orient="records", force_ascii=False)
+
diff --git a/public/machine_learning/scripts/proposals_summary_comments_textrank.py b/public/machine_learning/scripts/proposals_summary_comments_textrank.py
new file mode 100644
index 000000000..2af827979
--- /dev/null
+++ b/public/machine_learning/scripts/proposals_summary_comments_textrank.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python
+# coding: utf-8
+
+# In[1]:
+
+
+"""
+Proposals comments summaries - Dummy script
+
+"""
+
+
+# In[2]:
+
+
+data_path = '../data'
+config_file = 'proposals_summary_comments_textrank.ini'
+logging_file ='proposals_summary_comments_textrank.log'
+
+
+# In[3]:
+
+
+# Input file:
+inputjsonfile = 'comments.json'
+
+# Output files:
+comments_summaries_filename = 'ml_comments_summaries_proposals.json'
+
+
+# In[4]:
+
+
+import os
+import pandas as pd
+
+
+# ### Read the comments
+
+# In[5]:
+
+
+# comments_input_df = pd.read_json(os.path.join(data_path,inputjsonfile),orient="records")
+# col_id = 'commentable_id'
+# col_content = 'body'
+# comments_input_df = comments_input_df[[col_id]+[col_content]]
+
+
+# ### Create file. Comments summaries
+
+# In[6]:
+
+
+comments_summaries_cols = ['id','commentable_id','commentable_type','body']
+comments_summaries_df = pd.DataFrame(columns=comments_summaries_cols)
+row = [0,0,'Proposal','Summary']
+comments_summaries_df = comments_summaries_df.append(dict(zip(comments_summaries_cols,row)), ignore_index=True)
+comments_summaries_df.to_json(os.path.join(data_path,comments_summaries_filename),orient="records", force_ascii=False)
+
diff --git a/spec/components/machine_learning/comments_summary_component_spec.rb b/spec/components/machine_learning/comments_summary_component_spec.rb
new file mode 100644
index 000000000..993077b65
--- /dev/null
+++ b/spec/components/machine_learning/comments_summary_component_spec.rb
@@ -0,0 +1,44 @@
+require "rails_helper"
+
+describe MachineLearning::CommentsSummaryComponent, type: :component do
+ let(:commentable) { double(summary_comment: double(body: "There's a general agreement")) }
+ let(:component) { MachineLearning::CommentsSummaryComponent.new(commentable) }
+
+ before do
+ Setting["feature.machine_learning"] = true
+ Setting["machine_learning.comments_summary"] = true
+ allow(controller).to receive(:current_user).and_return(nil)
+ end
+
+ it "is displayed when the setting is enabled" do
+ render_inline component
+
+ expect(page).to have_content "Comments summary"
+ expect(page).to have_content "There's a general agreement"
+ expect(page).to have_content "Content generated by AI / Machine Learning"
+ end
+
+ it "is not displayed when the setting is disabled" do
+ Setting["machine_learning.comments_summary"] = false
+
+ render_inline component
+
+ expect(page.native.inner_html).to be_empty
+ end
+
+ it "is not displayed when the machine learning feature is disabled" do
+ Setting["feature.machine_learning"] = false
+
+ render_inline component
+
+ expect(page.native.inner_html).to be_empty
+ end
+
+ it "is not displayed when there's no summary" do
+ commentable = double(summary_comment: double(body: ""))
+
+ render_inline MachineLearning::CommentsSummaryComponent.new(commentable)
+
+ expect(page.native.inner_html).to be_empty
+ end
+end
diff --git a/spec/components/relationable/related_list_component_spec.rb b/spec/components/relationable/related_list_component_spec.rb
new file mode 100644
index 000000000..55ab43729
--- /dev/null
+++ b/spec/components/relationable/related_list_component_spec.rb
@@ -0,0 +1,48 @@
+require "rails_helper"
+
+describe Relationable::RelatedListComponent, type: :component do
+ let(:proposal) { create(:proposal) }
+ let(:user_proposal) { create(:proposal, title: "I am user related") }
+ let(:machine_proposal) { create(:proposal, title: "I am machine related") }
+ let(:component) { Relationable::RelatedListComponent.new(proposal) }
+
+ before do
+ Setting["feature.machine_learning"] = true
+ Setting["machine_learning.related_content"] = true
+
+ create(:related_content, parent_relationable: proposal, child_relationable: user_proposal)
+ create(:related_content, parent_relationable: proposal,
+ child_relationable: machine_proposal,
+ machine_learning: true)
+
+ allow(controller).to receive(:current_user).and_return(nil)
+ end
+
+ it "displays machine learning and user content when machine learning is enabled" do
+ render_inline component
+
+ expect(page).to have_css "li", count: 2
+ expect(page).to have_content "I am machine related"
+ expect(page).to have_content "I am user related"
+ end
+
+ it "displays user related content when machine learning is disabled" do
+ Setting["feature.machine_learning"] = false
+
+ render_inline component
+
+ expect(page).to have_css "li", count: 1
+ expect(page).to have_content "I am user related"
+ expect(page).not_to have_content "I am machine related"
+ end
+
+ it "displays user related content when machine learning related content is disabled" do
+ Setting["machine_learning.related_content"] = false
+
+ render_inline component
+
+ expect(page).to have_css "li", count: 1
+ expect(page).to have_content "I am user related"
+ expect(page).not_to have_content "I am machine related"
+ end
+end
diff --git a/spec/components/shared/tag_list_component_spec.rb b/spec/components/shared/tag_list_component_spec.rb
new file mode 100644
index 000000000..be128370f
--- /dev/null
+++ b/spec/components/shared/tag_list_component_spec.rb
@@ -0,0 +1,48 @@
+require "rails_helper"
+
+describe Shared::TagListComponent, type: :component do
+ let(:user_tag) { create(:tag, name: "user tag") }
+ let(:ml_tag) { create(:tag, name: "machine learning tag") }
+ let(:proposal) { create(:proposal, tag_list: [user_tag], ml_tag_list: [ml_tag]) }
+ let(:component) { Shared::TagListComponent.new(proposal, limit: nil) }
+
+ before do
+ Setting["feature.machine_learning"] = true
+ Setting["machine_learning.tags"] = true
+ allow(controller).to receive(:current_user).and_return(create(:administrator).user)
+ end
+
+ it "displays machine learning tags when machine learning is enabled" do
+ render_inline component
+
+ expect(page).not_to have_link "user tag"
+ expect(page).to have_link "machine learning tag"
+ expect(page).to have_content "Content generated by AI / Machine Learning"
+ end
+
+ it "displays user tags when machine learning is disabled" do
+ Setting["feature.machine_learning"] = false
+
+ render_inline component
+
+ expect(page).to have_link "user tag"
+ expect(page).not_to have_link "machine learning tag"
+ expect(page).not_to have_content "Content generated by AI / Machine Learning"
+ end
+
+ it "displays user tags when machine learning tags are disabled" do
+ Setting["machine_learning.tags"] = false
+
+ render_inline component
+
+ expect(page).to have_link "user tag"
+ expect(page).not_to have_link "machine learning tag"
+ expect(page).not_to have_content "Content generated by AI / Machine Learning"
+ end
+
+ it "is not rendered when there are no tags" do
+ render_inline Shared::TagListComponent.new(Proposal.new, limit: nil)
+
+ expect(page.native.inner_html).to be_empty
+ end
+end
diff --git a/spec/factories/classifications.rb b/spec/factories/classifications.rb
index d1f107814..c48c0e0f6 100644
--- a/spec/factories/classifications.rb
+++ b/spec/factories/classifications.rb
@@ -41,6 +41,20 @@ FactoryBot.define do
association :author, factory: :user
association :parent_relationable, factory: [:proposal, :debate].sample
association :child_relationable, factory: [:proposal, :debate].sample
+
+ trait :proposals do
+ association :parent_relationable, factory: :proposal
+ association :child_relationable, factory: :proposal
+ end
+
+ trait :budget_investments do
+ association :parent_relationable, factory: :budget_investment
+ association :child_relationable, factory: :budget_investment
+ end
+
+ trait :from_machine_learning do
+ machine_learning { true }
+ end
end
factory :related_content_score do
diff --git a/spec/factories/machine_learning.rb b/spec/factories/machine_learning.rb
new file mode 100644
index 000000000..98e77984e
--- /dev/null
+++ b/spec/factories/machine_learning.rb
@@ -0,0 +1,19 @@
+FactoryBot.define do
+ factory :machine_learning_job do
+ association :user, factory: :user
+ script { "script.py" }
+ started_at { Time.current }
+ finished_at { nil }
+ error { nil }
+ end
+
+ factory :machine_learning_info do
+ kind { "tags" }
+ generated_at { Time.current }
+ script { "script.py" }
+ end
+
+ factory :ml_summary_comment do
+ body { "Summary comment generated by Machine Learning" }
+ end
+end
diff --git a/spec/models/machine_learning_spec.rb b/spec/models/machine_learning_spec.rb
new file mode 100644
index 000000000..ebbb1c607
--- /dev/null
+++ b/spec/models/machine_learning_spec.rb
@@ -0,0 +1,610 @@
+require "rails_helper"
+
+describe MachineLearning do
+ def full_sanitizer(string)
+ ActionView::Base.full_sanitizer.sanitize(string)
+ end
+
+ let(:job) { create(:machine_learning_job) }
+
+ describe "#cleanup_proposals_tags!" do
+ it "does not delete other machine learning generated data" do
+ create(:ml_summary_comment, commentable: create(:proposal))
+ create(:ml_summary_comment, commentable: create(:budget_investment))
+
+ create(:related_content, :proposals, :from_machine_learning)
+ create(:related_content, :budget_investments, :from_machine_learning)
+
+ expect(MlSummaryComment.count).to be 2
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_proposals_tags!)
+
+ expect(MlSummaryComment.count).to be 2
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+ end
+
+ it "deletes proposals tags machine learning generated data" do
+ proposal = create(:proposal)
+ investment = create(:budget_investment)
+
+ user_tag = create(:tag)
+ create(:tagging, tag: user_tag, taggable: proposal)
+
+ ml_proposal_tag = create(:tag)
+ create(:tagging, tag: ml_proposal_tag, taggable: proposal, context: "ml_tags")
+
+ ml_investment_tag = create(:tag)
+ create(:tagging, tag: ml_investment_tag, taggable: investment, context: "ml_tags")
+
+ common_tag = create(:tag)
+ create(:tagging, tag: common_tag, taggable: proposal)
+ create(:tagging, tag: common_tag, taggable: proposal, context: "ml_tags")
+ create(:tagging, tag: common_tag, taggable: investment, context: "ml_tags")
+
+ expect(Tag.count).to be 4
+ expect(Tagging.count).to be 6
+ expect(Tagging.where(context: "tags").count).to be 2
+ expect(Tagging.where(context: "ml_tags", taggable_type: "Proposal").count).to be 2
+ expect(Tagging.where(context: "ml_tags", taggable_type: "Budget::Investment").count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_proposals_tags!)
+
+ expect(Tag.count).to be 3
+ expect(Tag.all).not_to include ml_proposal_tag
+ expect(Tagging.count).to be 4
+ expect(Tagging.where(context: "tags").count).to be 2
+ expect(Tagging.where(context: "ml_tags", taggable_type: "Proposal")).to be_empty
+ expect(Tagging.where(context: "ml_tags", taggable_type: "Budget::Investment").count).to be 2
+ end
+ end
+
+ describe "#cleanup_investments_tags!" do
+ it "does not delete other machine learning generated data" do
+ create(:ml_summary_comment, commentable: create(:proposal))
+ create(:ml_summary_comment, commentable: create(:budget_investment))
+
+ create(:related_content, :proposals, :from_machine_learning)
+ create(:related_content, :budget_investments, :from_machine_learning)
+
+ expect(MlSummaryComment.count).to be 2
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_investments_tags!)
+
+ expect(MlSummaryComment.count).to be 2
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+ end
+
+ it "deletes investments tags machine learning generated data" do
+ proposal = create(:proposal)
+ investment = create(:budget_investment)
+
+ user_tag = create(:tag)
+ create(:tagging, tag: user_tag, taggable: investment)
+
+ ml_investment_tag = create(:tag)
+ create(:tagging, tag: ml_investment_tag, taggable: investment, context: "ml_tags")
+
+ ml_proposal_tag = create(:tag)
+ create(:tagging, tag: ml_proposal_tag, taggable: proposal, context: "ml_tags")
+
+ common_tag = create(:tag)
+ create(:tagging, tag: common_tag, taggable: investment)
+ create(:tagging, tag: common_tag, taggable: investment, context: "ml_tags")
+ create(:tagging, tag: common_tag, taggable: proposal, context: "ml_tags")
+
+ expect(Tag.count).to be 4
+ expect(Tagging.count).to be 6
+ expect(Tagging.where(context: "tags").count).to be 2
+ expect(Tagging.where(context: "ml_tags", taggable_type: "Budget::Investment").count).to be 2
+ expect(Tagging.where(context: "ml_tags", taggable_type: "Proposal").count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_investments_tags!)
+
+ expect(Tag.count).to be 3
+ expect(Tag.all).not_to include ml_investment_tag
+ expect(Tagging.count).to be 4
+ expect(Tagging.where(context: "tags").count).to be 2
+ expect(Tagging.where(context: "ml_tags", taggable_type: "Budget::Investment")).to be_empty
+ expect(Tagging.where(context: "ml_tags", taggable_type: "Proposal").count).to be 2
+ end
+ end
+
+ describe "#cleanup_proposals_related_content!" do
+ it "does not delete other machine learning generated data" do
+ proposal = create(:proposal)
+ investment = create(:budget_investment)
+
+ create(:ml_summary_comment, commentable: proposal)
+ create(:ml_summary_comment, commentable: investment)
+
+ create(:tagging, tag: create(:tag))
+ create(:tagging, tag: create(:tag), context: "ml_tags", taggable: proposal)
+ create(:tagging, tag: create(:tag), context: "ml_tags", taggable: investment)
+
+ expect(MlSummaryComment.count).to be 2
+ expect(Tag.count).to be 3
+ expect(Tagging.count).to be 3
+ expect(Tagging.where(context: "tags").count).to be 1
+ expect(Tagging.where(context: "ml_tags").count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_proposals_related_content!)
+
+ expect(MlSummaryComment.count).to be 2
+ expect(Tag.count).to be 3
+ expect(Tagging.count).to be 3
+ expect(Tagging.where(context: "tags").count).to be 1
+ expect(Tagging.where(context: "ml_tags").count).to be 2
+ end
+
+ it "deletes proposals related content machine learning generated data" do
+ create(:related_content, :proposals)
+ create(:related_content, :budget_investments)
+ create(:related_content, :proposals, :from_machine_learning)
+ create(:related_content, :budget_investments, :from_machine_learning)
+
+ expect(RelatedContent.for_proposals.from_users.count).to be 2
+ expect(RelatedContent.for_investments.from_users.count).to be 2
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_proposals_related_content!)
+
+ expect(RelatedContent.for_proposals.from_users.count).to be 2
+ expect(RelatedContent.for_investments.from_users.count).to be 2
+ expect(RelatedContent.for_proposals.from_machine_learning).to be_empty
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+ end
+ end
+
+ describe "#cleanup_investments_related_content!" do
+ it "does not delete other machine learning generated data" do
+ proposal = create(:proposal)
+ investment = create(:budget_investment)
+
+ create(:ml_summary_comment, commentable: proposal)
+ create(:ml_summary_comment, commentable: investment)
+
+ create(:tagging, tag: create(:tag))
+ create(:tagging, tag: create(:tag), context: "ml_tags", taggable: proposal)
+ create(:tagging, tag: create(:tag), context: "ml_tags", taggable: investment)
+
+ expect(MlSummaryComment.count).to be 2
+ expect(Tag.count).to be 3
+ expect(Tagging.count).to be 3
+ expect(Tagging.where(context: "tags").count).to be 1
+ expect(Tagging.where(context: "ml_tags").count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_investments_related_content!)
+
+ expect(MlSummaryComment.count).to be 2
+ expect(Tag.count).to be 3
+ expect(Tagging.count).to be 3
+ expect(Tagging.where(context: "tags").count).to be 1
+ expect(Tagging.where(context: "ml_tags").count).to be 2
+ end
+
+ it "deletes proposals related content machine learning generated data" do
+ create(:related_content, :proposals)
+ create(:related_content, :budget_investments)
+ create(:related_content, :proposals, :from_machine_learning)
+ create(:related_content, :budget_investments, :from_machine_learning)
+
+ expect(RelatedContent.for_proposals.from_users.count).to be 2
+ expect(RelatedContent.for_investments.from_users.count).to be 2
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_investments_related_content!)
+
+ expect(RelatedContent.for_proposals.from_users.count).to be 2
+ expect(RelatedContent.for_investments.from_users.count).to be 2
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning).to be_empty
+ end
+ end
+
+ describe "#cleanup_proposals_comments_summary!" do
+ it "does not delete other machine learning generated data" do
+ create(:related_content, :proposals, :from_machine_learning)
+ create(:related_content, :budget_investments, :from_machine_learning)
+
+ create(:tagging, tag: create(:tag))
+ create(:tagging, tag: create(:tag), context: "ml_tags", taggable: create(:proposal))
+ create(:tagging, tag: create(:tag), context: "ml_tags", taggable: create(:budget_investment))
+
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+ expect(Tag.count).to be 3
+ expect(Tagging.count).to be 3
+ expect(Tagging.where(context: "tags").count).to be 1
+ expect(Tagging.where(context: "ml_tags").count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_proposals_comments_summary!)
+
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+ expect(Tag.count).to be 3
+ expect(Tagging.count).to be 3
+ expect(Tagging.where(context: "tags").count).to be 1
+ expect(Tagging.where(context: "ml_tags").count).to be 2
+ end
+
+ it "deletes proposals comments summary machine learning generated data" do
+ create(:ml_summary_comment, commentable: create(:proposal))
+ create(:ml_summary_comment, commentable: create(:budget_investment))
+
+ expect(MlSummaryComment.where(commentable_type: "Proposal").count).to be 1
+ expect(MlSummaryComment.where(commentable_type: "Budget::Investment").count).to be 1
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_proposals_comments_summary!)
+
+ expect(MlSummaryComment.where(commentable_type: "Proposal")).to be_empty
+ expect(MlSummaryComment.where(commentable_type: "Budget::Investment").count).to be 1
+ end
+ end
+
+ describe "#cleanup_investments_comments_summary!" do
+ it "does not delete other machine learning generated data" do
+ create(:related_content, :proposals, :from_machine_learning)
+ create(:related_content, :budget_investments, :from_machine_learning)
+
+ create(:tagging, tag: create(:tag))
+ create(:tagging, tag: create(:tag), context: "ml_tags", taggable: create(:proposal))
+ create(:tagging, tag: create(:tag), context: "ml_tags", taggable: create(:budget_investment))
+
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+ expect(Tag.count).to be 3
+ expect(Tagging.count).to be 3
+ expect(Tagging.where(context: "tags").count).to be 1
+ expect(Tagging.where(context: "ml_tags").count).to be 2
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_investments_comments_summary!)
+
+ expect(RelatedContent.for_proposals.from_machine_learning.count).to be 2
+ expect(RelatedContent.for_investments.from_machine_learning.count).to be 2
+ expect(Tag.count).to be 3
+ expect(Tagging.count).to be 3
+ expect(Tagging.where(context: "tags").count).to be 1
+ expect(Tagging.where(context: "ml_tags").count).to be 2
+ end
+
+ it "deletes budget investments comments summary machine learning generated data" do
+ create(:ml_summary_comment, commentable: create(:proposal))
+ create(:ml_summary_comment, commentable: create(:budget_investment))
+
+ expect(MlSummaryComment.where(commentable_type: "Proposal").count).to be 1
+ expect(MlSummaryComment.where(commentable_type: "Budget::Investment").count).to be 1
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:cleanup_investments_comments_summary!)
+
+ expect(MlSummaryComment.where(commentable_type: "Proposal").count).to be 1
+ expect(MlSummaryComment.where(commentable_type: "Budget::Investment")).to be_empty
+ end
+ end
+
+ describe "#export_proposals_to_json" do
+ it "creates a JSON file with all proposals" do
+ require "fileutils"
+ FileUtils.mkdir_p Rails.root.join("public", "machine_learning", "data")
+
+ first_proposal = create(:proposal)
+ last_proposal = create(:proposal)
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:export_proposals_to_json)
+
+ json_file = MachineLearning::DATA_FOLDER.join("proposals.json")
+ json = JSON.parse(File.read(json_file))
+
+ expect(json).to be_an Array
+ expect(json.size).to be 2
+
+ expect(json.first["id"]).to eq first_proposal.id
+ expect(json.first["title"]).to eq first_proposal.title
+ expect(json.first["summary"]).to eq full_sanitizer(first_proposal.summary)
+ expect(json.first["description"]).to eq full_sanitizer(first_proposal.description)
+
+ expect(json.last["id"]).to eq last_proposal.id
+ expect(json.last["title"]).to eq last_proposal.title
+ expect(json.last["summary"]).to eq full_sanitizer(last_proposal.summary)
+ expect(json.last["description"]).to eq full_sanitizer(last_proposal.description)
+ end
+ end
+
+ describe "#export_budget_investments_to_json" do
+ it "creates a JSON file with all budget investments" do
+ require "fileutils"
+ FileUtils.mkdir_p Rails.root.join("public", "machine_learning", "data")
+
+ first_budget_investment = create(:budget_investment)
+ last_budget_investment = create(:budget_investment)
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:export_budget_investments_to_json)
+
+ json_file = MachineLearning::DATA_FOLDER.join("budget_investments.json")
+ json = JSON.parse(File.read(json_file))
+
+ expect(json).to be_an Array
+ expect(json.size).to be 2
+
+ expect(json.first["id"]).to eq first_budget_investment.id
+ expect(json.first["title"]).to eq first_budget_investment.title
+ expect(json.first["description"]).to eq full_sanitizer(first_budget_investment.description)
+
+ expect(json.last["id"]).to eq last_budget_investment.id
+ expect(json.last["title"]).to eq last_budget_investment.title
+ expect(json.last["description"]).to eq full_sanitizer(last_budget_investment.description)
+ end
+ end
+
+ describe "#export_comments_to_json" do
+ it "creates a JSON file with all comments" do
+ require "fileutils"
+ FileUtils.mkdir_p Rails.root.join("public", "machine_learning", "data")
+
+ first_comment = create(:comment)
+ last_comment = create(:comment)
+
+ machine_learning = MachineLearning.new(job)
+ machine_learning.send(:export_comments_to_json)
+
+ json_file = MachineLearning::DATA_FOLDER.join("comments.json")
+ json = JSON.parse(File.read(json_file))
+
+ expect(json).to be_an Array
+ expect(json.size).to be 2
+
+ expect(json.first["id"]).to eq first_comment.id
+ expect(json.first["commentable_id"]).to eq first_comment.commentable_id
+ expect(json.first["commentable_type"]).to eq first_comment.commentable_type
+ expect(json.first["body"]).to eq full_sanitizer(first_comment.body)
+
+ expect(json.last["id"]).to eq last_comment.id
+ expect(json.last["commentable_id"]).to eq last_comment.commentable_id
+ expect(json.last["commentable_type"]).to eq last_comment.commentable_type
+ expect(json.last["body"]).to eq full_sanitizer(last_comment.body)
+ end
+ end
+
+ describe "#run_machine_learning_scripts" do
+ it "returns true if python script executed correctly" do
+ machine_learning = MachineLearning.new(job)
+
+ command = "cd #{MachineLearning::SCRIPTS_FOLDER} && python script.py 2>&1"
+ expect(machine_learning).to receive(:`).with(command) do
+ Process.waitpid Process.fork { exit 0 }
+ end
+
+ expect(Mailer).not_to receive(:machine_learning_error)
+
+ expect(machine_learning.send(:run_machine_learning_scripts)).to be true
+
+ job.reload
+ expect(job.finished_at).not_to be_present
+ expect(job.error).not_to be_present
+ end
+
+ it "returns false if python script errored" do
+ machine_learning = MachineLearning.new(job)
+
+ command = "cd #{MachineLearning::SCRIPTS_FOLDER} && python script.py 2>&1"
+ expect(machine_learning).to receive(:`).with(command) do
+ Process.waitpid Process.fork { abort "error message" }
+ end
+
+ mailer = double("mailer")
+ expect(mailer).to receive(:deliver_later)
+ expect(Mailer).to receive(:machine_learning_error).and_return mailer
+
+ expect(machine_learning.send(:run_machine_learning_scripts)).to be false
+
+ job.reload
+ expect(job.finished_at).to be_present
+ expect(job.error).not_to eq "error message"
+ end
+ end
+
+ describe "#import_ml_proposals_comments_summary" do
+ it "feeds the database using content from the JSON file generated by the machine learning script" do
+ machine_learning = MachineLearning.new(job)
+
+ proposal = create(:proposal)
+
+ data = [
+ { commentable_id: proposal.id,
+ commentable_type: "Proposal",
+ body: "Summary comment for proposal with ID #{proposal.id}" }
+ ]
+
+ filename = "ml_comments_summaries_proposals.json"
+ json_file = MachineLearning::DATA_FOLDER.join(filename)
+ expect(File).to receive(:read).with(json_file).and_return data.to_json
+
+ machine_learning.send(:import_ml_proposals_comments_summary)
+
+ expect(proposal.summary_comment.body).to eq "Summary comment for proposal with ID #{proposal.id}"
+ end
+ end
+
+ describe "#import_ml_investments_comments_summary" do
+ it "feeds the database using content from the JSON file generated by the machine learning script" do
+ machine_learning = MachineLearning.new(job)
+
+ investment = create(:budget_investment)
+
+ data = [
+ { commentable_id: investment.id,
+ commentable_type: "Budget::Investment",
+ body: "Summary comment for investment with ID #{investment.id}" }
+ ]
+
+ filename = "ml_comments_summaries_budgets.json"
+ json_file = MachineLearning::DATA_FOLDER.join(filename)
+ expect(File).to receive(:read).with(json_file).and_return data.to_json
+
+ machine_learning.send(:import_ml_investments_comments_summary)
+
+ expect(investment.summary_comment.body).to eq "Summary comment for investment with ID #{investment.id}"
+ end
+ end
+
+ describe "#import_proposals_related_content" do
+ it "feeds the database using content from the JSON file generated by the machine learning script" do
+ machine_learning = MachineLearning.new(job)
+
+ proposal = create(:proposal)
+ related_proposal = create(:proposal)
+ other_related_proposal = create(:proposal)
+
+ data = [
+ {
+ "id" => proposal.id,
+ "related1" => related_proposal.id,
+ "related2" => other_related_proposal.id
+ }
+ ]
+
+ filename = "ml_related_content_proposals.json"
+ json_file = MachineLearning::DATA_FOLDER.join(filename)
+ expect(File).to receive(:read).with(json_file).and_return data.to_json
+
+ machine_learning.send(:import_proposals_related_content)
+
+ expect(proposal.related_contents.count).to be 2
+ expect(proposal.related_contents.first.child_relationable).to eq related_proposal
+ expect(proposal.related_contents.last.child_relationable).to eq other_related_proposal
+ end
+ end
+
+ describe "#import_budget_investments_related_content" do
+ it "feeds the database using content from the JSON file generated by the machine learning script" do
+ machine_learning = MachineLearning.new(job)
+
+ investment = create(:budget_investment)
+ related_investment = create(:budget_investment)
+ other_related_investment = create(:budget_investment)
+
+ data = [
+ {
+ "id" => investment.id,
+ "related1" => related_investment.id,
+ "related2" => other_related_investment.id
+ }
+ ]
+
+ filename = "ml_related_content_budgets.json"
+ json_file = MachineLearning::DATA_FOLDER.join(filename)
+ expect(File).to receive(:read).with(json_file).and_return data.to_json
+
+ machine_learning.send(:import_budget_investments_related_content)
+
+ expect(investment.related_contents.count).to be 2
+ expect(investment.related_contents.first.child_relationable).to eq related_investment
+ expect(investment.related_contents.last.child_relationable).to eq other_related_investment
+ end
+ end
+
+ describe "#import_ml_proposals_tags" do
+ it "feeds the database using content from the JSON file generated by the machine learning script" do
+ create(:tag, name: "Existing tag")
+ proposal = create(:proposal)
+ machine_learning = MachineLearning.new(job)
+
+ tags_data = [
+ { id: 0,
+ name: "Existing tag" },
+ { id: 1,
+ name: "Machine learning tag" }
+ ]
+
+ taggings_data = [
+ { tag_id: 0,
+ taggable_id: proposal.id
+ },
+ { tag_id: 1,
+ taggable_id: proposal.id
+ }
+ ]
+
+ tags_filename = "ml_tags_proposals.json"
+ tags_json_file = MachineLearning::DATA_FOLDER.join(tags_filename)
+ expect(File).to receive(:read).with(tags_json_file).and_return tags_data.to_json
+
+ taggings_filename = "ml_taggings_proposals.json"
+ taggings_json_file = MachineLearning::DATA_FOLDER.join(taggings_filename)
+ expect(File).to receive(:read).with(taggings_json_file).and_return taggings_data.to_json
+
+ machine_learning.send(:import_ml_proposals_tags)
+
+ expect(Tag.count).to be 2
+ expect(Tag.first.name).to eq "Existing tag"
+ expect(Tag.last.name).to eq "Machine learning tag"
+ expect(proposal.tags).to be_empty
+ expect(proposal.ml_tags.count).to be 2
+ expect(proposal.ml_tags.first.name).to eq "Existing tag"
+ expect(proposal.ml_tags.last.name).to eq "Machine learning tag"
+ end
+ end
+
+ describe "#import_ml_investments_tags" do
+ it "feeds the database using content from the JSON file generated by the machine learning script" do
+ create(:tag, name: "Existing tag")
+ investment = create(:budget_investment)
+ machine_learning = MachineLearning.new(job)
+
+ tags_data = [
+ { id: 0,
+ name: "Existing tag" },
+ { id: 1,
+ name: "Machine learning tag" }
+ ]
+
+ taggings_data = [
+ { tag_id: 0,
+ taggable_id: investment.id
+ },
+ { tag_id: 1,
+ taggable_id: investment.id
+ }
+ ]
+
+ tags_filename = "ml_tags_budgets.json"
+ tags_json_file = MachineLearning::DATA_FOLDER.join(tags_filename)
+ expect(File).to receive(:read).with(tags_json_file).and_return tags_data.to_json
+
+ taggings_filename = "ml_taggings_budgets.json"
+ taggings_json_file = MachineLearning::DATA_FOLDER.join(taggings_filename)
+ expect(File).to receive(:read).with(taggings_json_file).and_return taggings_data.to_json
+
+ machine_learning.send(:import_ml_investments_tags)
+
+ expect(Tag.count).to be 2
+ expect(Tag.first.name).to eq "Existing tag"
+ expect(Tag.last.name).to eq "Machine learning tag"
+ expect(investment.tags).to be_empty
+ expect(investment.ml_tags.count).to be 2
+ expect(investment.ml_tags.first.name).to eq "Existing tag"
+ expect(investment.ml_tags.last.name).to eq "Machine learning tag"
+ end
+ end
+end
diff --git a/spec/system/admin/machine_learning_spec.rb b/spec/system/admin/machine_learning_spec.rb
new file mode 100644
index 000000000..d5db40a76
--- /dev/null
+++ b/spec/system/admin/machine_learning_spec.rb
@@ -0,0 +1,255 @@
+require "rails_helper"
+
+describe "Machine learning" do
+ let(:admin) { create(:administrator) }
+
+ before do
+ login_as(admin.user)
+ Setting["feature.machine_learning"] = true
+ end
+
+ scenario "Section does not appear if feature is not enabled" do
+ Setting["feature.machine_learning"] = false
+
+ visit admin_root_path
+
+ within "#admin_menu" do
+ expect(page).not_to have_link "AI / Machine learning"
+ end
+ end
+
+ scenario "Section appears if feature is enabled" do
+ visit admin_root_path
+
+ within "#admin_menu" do
+ expect(page).to have_link "AI / Machine learning"
+ end
+
+ click_link "AI / Machine learning"
+
+ expect(page).to have_content "AI / Machine learning"
+ expect(page).to have_content "This functionality is experimental"
+ expect(page).to have_link "Execute script"
+ expect(page).to have_link "Settings / Generated content"
+ expect(page).to have_link "Help"
+ expect(page).to have_current_path(admin_machine_learning_path)
+ end
+
+ scenario "Show message if feature is disabled" do
+ Setting["feature.machine_learning"] = false
+
+ visit admin_machine_learning_path
+
+ expect(page).to have_content "This feature is disabled. To use Machine Learning you can enable it from "\
+ "the settings page"
+ expect(page).to have_link "settings page", href: admin_settings_path(anchor: "tab-feature-flags")
+ end
+
+ scenario "Script executed sucessfully" do
+ allow_any_instance_of(MachineLearning).to receive(:run) do
+ MachineLearningJob.first.update!(finished_at: Time.current)
+ end
+
+ visit admin_machine_learning_path
+
+ select "proposals_related_content_and_tags_nmf.py", from: "Select python script to execute"
+ click_button "Execute script"
+
+ expect(page).to have_content "The last script has been executed successfully."
+ expect(page).to have_content "You will receive an email in #{admin.email} when the script "\
+ "finishes running."
+
+ expect(page).to have_field "Select python script to execute"
+ expect(page).to have_button "Execute script"
+ end
+
+ scenario "Settings" do
+ visit admin_machine_learning_path
+
+ within "#machine_learning_tabs" do
+ click_link "Settings / Generated content"
+ end
+
+ expect(page).to have_content "Related content"
+ expect(page).to have_content "Adds automatically generated related content to proposals and "\
+ "participatory budget projects"
+
+ expect(page).to have_content "Comments summary"
+ expect(page).to have_content "Displays an automatically generated comment summary on all items that "\
+ "can be commented on."
+
+ expect(page).to have_content "Tags"
+ expect(page).to have_content "Generates automatic tags on all items that can be tagged on."
+
+ expect(page).to have_content "No content generated yet", count: 3
+ expect(page).not_to have_button "Yes"
+ expect(page).not_to have_button "No"
+ end
+
+ scenario "Script started but not finished yet" do
+ allow_any_instance_of(MachineLearning).to receive(:run)
+
+ visit admin_machine_learning_path
+
+ select "proposals_related_content_and_tags_nmf.py", from: "Select python script to execute"
+ click_button "Execute script"
+
+ expect(page).to have_content "The script is running. The administrator who executed it will receive "\
+ "an email when it is finished."
+
+ expect(page).to have_content "Executed by: #{admin.name}"
+ expect(page).to have_content "Script name: proposals_related_content_and_tags_nmf.py"
+ expect(page).to have_content "Started at:"
+
+ expect(page).not_to have_content "Select python script to execute"
+ expect(page).not_to have_button "Execute script"
+ end
+
+ scenario "Admin can cancel operation if script is working for too long" do
+ allow_any_instance_of(MachineLearning).to receive(:run) do
+ MachineLearningJob.first.update!(started_at: 25.hours.ago)
+ end
+
+ visit admin_machine_learning_path
+
+ select "proposals_related_content_and_tags_nmf.py", from: "Select python script to execute"
+ click_button "Execute script"
+
+ accept_confirm { click_button "Cancel operation" }
+
+ expect(page).to have_content "Generated content has been successfully deleted."
+
+ expect(page).to have_field "Select python script to execute"
+ expect(page).to have_button "Execute script"
+ end
+
+ scenario "Script finished with an error" do
+ allow_any_instance_of(MachineLearning).to receive(:run) do
+ MachineLearningJob.first.update!(finished_at: Time.current, error: "Error description")
+ end
+
+ visit admin_machine_learning_path
+
+ select "proposals_related_content_and_tags_nmf.py", from: "Select python script to execute"
+ click_button "Execute script"
+
+ expect(page).to have_content "An error has occurred. You can see the details below."
+
+ expect(page).to have_content "Executed by: #{admin.name}"
+ expect(page).to have_content "Script name: proposals_related_content_and_tags_nmf.py"
+ expect(page).to have_content "Error: Error description"
+
+ expect(page).to have_content "You will receive an email in #{admin.email} when the script "\
+ "finishes running."
+
+ expect(page).to have_field "Select python script to execute"
+ expect(page).to have_button "Execute script"
+ end
+
+ scenario "Email content received by the user who execute the script" do
+ reset_mailer
+ Mailer.machine_learning_success(admin.user).deliver
+
+ email = open_last_email
+ expect(email).to have_subject "Machine Learning - Content has been generated successfully"
+ expect(email).to have_content "Machine Learning script"
+ expect(email).to have_content "Content has been generated successfully."
+ expect(email).to have_link "Visit Machine Learning panel"
+ expect(email).to deliver_to(admin.user.email)
+
+ reset_mailer
+ Mailer.machine_learning_error(admin.user).deliver
+
+ email = open_last_email
+ expect(email).to have_subject "Machine Learning - An error has occurred running the script"
+ expect(email).to have_content "Machine Learning script"
+ expect(email).to have_content "An error has occurred running the Machine Learning script."
+ expect(email).to have_link "Visit Machine Learning panel"
+ expect(email).to deliver_to(admin.user.email)
+ end
+
+ scenario "Machine Learning visualization settings are disabled by default" do
+ allow_any_instance_of(MachineLearning).to receive(:run) do
+ MachineLearningJob.first.update!(finished_at: Time.current)
+ end
+
+ visit admin_machine_learning_path
+
+ select "proposals_related_content_and_tags_nmf.py", from: "Select python script to execute"
+ click_button "Execute script"
+
+ expect(page).to have_content "The last script has been executed successfully."
+
+ within "#machine_learning_tabs" do
+ click_link "Settings / Generated content"
+ end
+
+ expect(page).to have_content "No content generated yet", count: 3
+ expect(page).not_to have_button "Yes"
+ expect(page).not_to have_button "No"
+ end
+
+ scenario "Show script descriptions" do
+ visit admin_machine_learning_path
+
+ select "proposals_summary_comments_textrank.py", from: "Select python script to execute"
+
+ within "#script_descriptions" do
+ expect(page).to have_content "Proposals comments summaries - Dummy script"
+ end
+
+ select "proposals_related_content_and_tags_nmf.py", from: "Select python script to execute"
+
+ within "#script_descriptions" do
+ expect(page).to have_content "Related Proposals and Tags - Dummy script"
+ expect(page).not_to have_content "Proposals comments summaries - Dummy script"
+ end
+ end
+
+ scenario "Show output files info on settins page" do
+ require "fileutils"
+ FileUtils.mkdir_p Rails.root.join("public", "machine_learning", "data")
+
+ allow_any_instance_of(MachineLearning).to receive(:run) do
+ MachineLearningJob.first.update!(finished_at: Time.current + 2.minutes)
+ create(:machine_learning_info,
+ script: "proposals_summary_comments_textrank.py",
+ kind: "comments_summary",
+ updated_at: Time.current + 2.minutes)
+ comments_file = MachineLearning::DATA_FOLDER.join(MachineLearning.comments_filename)
+ File.open(comments_file, "w") { |file| file.write([].to_json) }
+ proposals_comments_summary_file = MachineLearning::DATA_FOLDER.join(MachineLearning.proposals_comments_summary_filename)
+ File.open(proposals_comments_summary_file, "w") { |file| file.write([].to_json) }
+ end
+
+ visit admin_machine_learning_path
+
+ within "#machine_learning_tabs" do
+ click_link "Settings / Generated content"
+ end
+
+ expect(page).to have_content "No content generated yet.", count: 3
+
+ within "#machine_learning_tabs" do
+ click_link "Execute script"
+ end
+
+ travel_to(Time.zone.local(2019, 10, 20, 14, 57, 25)) do
+ select "proposals_related_content_and_tags_nmf.py", from: "Select python script to execute"
+ click_button "Execute script"
+
+ expect(page).to have_content "The last script has been executed successfully."
+ end
+
+ within "#machine_learning_tabs" do
+ click_link "Settings / Generated content"
+ end
+
+ expect(page).to have_content "Last execution\n2019-10-20 14:57 - 2019-10-20 14:59"
+ expect(page).to have_content "Executed script:"
+ expect(page).to have_content "proposals_summary_comments_textrank.py"
+ expect(page).to have_content "Output files:"
+ expect(page).to have_link "ml_comments_summaries_proposals.json",
+ href: "/machine_learning/data/ml_comments_summaries_proposals.json"
+ end
+end
diff --git a/spec/system/machine_learning_spec.rb b/spec/system/machine_learning_spec.rb
new file mode 100644
index 000000000..0017b5a76
--- /dev/null
+++ b/spec/system/machine_learning_spec.rb
@@ -0,0 +1,75 @@
+require "rails_helper"
+
+describe "Machine learning" do
+ let(:user_tag) { create(:tag, name: "user tag") }
+ let(:ml_proposal_tag) { create(:tag, name: "machine learning proposal tag") }
+ let(:ml_investment_tag) { create(:tag, name: "machine learning investment tag") }
+ let(:proposal) { create(:proposal) }
+ let(:related_proposal) { create(:proposal) }
+ let(:investment) { create(:budget_investment) }
+ let(:related_investment) { create(:budget_investment) }
+
+ before do
+ Setting["feature.machine_learning"] = true
+ Setting["machine_learning.comments_summary"] = true
+ Setting["machine_learning.related_content"] = true
+ Setting["machine_learning.tags"] = true
+
+ proposal.update!(tag_list: [user_tag])
+ proposal.update!(ml_tag_list: [ml_proposal_tag])
+ investment.update!(tag_list: [user_tag])
+ investment.update!(ml_tag_list: [ml_investment_tag])
+ end
+
+ scenario "proposal view" do
+ create(:ml_summary_comment, commentable: proposal, body: "Life is wonderful")
+ create(:related_content, parent_relationable: proposal,
+ child_relationable: related_proposal,
+ machine_learning: true)
+
+ visit proposal_path(proposal)
+
+ within "#tags_proposal_#{proposal.id}" do
+ expect(page).not_to have_link "user tag"
+ expect(page).to have_link "machine learning proposal tag"
+ expect(page).not_to have_link "machine learning investment tag"
+ end
+
+ within ".related-content" do
+ expect(page).to have_content "Related content (1)"
+ expect(page).to have_css ".related-content-title"
+ expect(page).to have_content related_proposal.title
+ end
+
+ within "#comments" do
+ expect(page).to have_content "Comments summary"
+ expect(page).to have_content "Life is wonderful"
+ end
+ end
+
+ scenario "investment view" do
+ create(:ml_summary_comment, commentable: investment, body: "Build in the main square")
+ create(:related_content, parent_relationable: investment,
+ child_relationable: related_investment,
+ machine_learning: true)
+
+ visit budget_investment_path(investment.budget, investment)
+
+ within "#tags_budget_investment_#{investment.id}" do
+ expect(page).not_to have_link "user tag"
+ expect(page).not_to have_link "machine learning proposal tag"
+ expect(page).to have_link "machine learning investment tag"
+ end
+
+ within ".related-content" do
+ expect(page).to have_content "Related content (1)"
+ expect(page).to have_css ".related-content-title", count: 1
+ expect(page).to have_content related_investment.title
+ end
+
+ within "#tab-comments" do
+ expect(page).to have_content "Comments summary"
+ expect(page).to have_content "Build in the main square"
+ end
+ end
+end
+ <%= render MachineLearning::InfoComponent.new %> + <%= t("machine_learning.comments_summary") %> +
+ +<%= simple_format(body) %>
+